2 Commits
Author SHA1 Message Date
sirmrak 66af4d1200 Обновить README.md 2025-12-19 23:31:43 +03:00
sirmrak 4cceda3ed6 Initial commit 2025-12-19 23:17:29 +03:00
111 changed files with 171 additions and 15536 deletions
Executable → Regular
+169 -27
View File
@@ -1,34 +1,176 @@
.DS_Store # ---> Python
.idea # Byte-compiled / optimized / DLL files
*.log
tmp/
*.py[cod]
*.egg
*.egg-info/
build
htmlcov
.venv/
.mypy_cache/
__pycache__/ __pycache__/
*.py[cod]
*$py.class
/dist/ # C extensions
/local/ *.so
# LED brightness config (auto-generated, user-specific) # Distribution / packaging
examples/led_brightness_config.json .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Qwen Code settings # PyInstaller
.qwen/settings.json.orig # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Backup files # Installer logs
*.old pip-log.txt
*.bak pip-delete-this-directory.txt
*~
# Archives # Unit test / coverage reports
*.zip htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Empty files
*.lgd-*
-6
View File
@@ -1,6 +0,0 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
-4
View File
@@ -1,4 +0,0 @@
# Инструкции по генерации файлов
- Всегда пиши файлы README.md, CHANGELOG.md и сопутствующую документацию на русском языке.
- Комментарии в коде также должны быть на русском языке.
- Используй технический, но понятный стиль.
-12
View File
@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(/home/sirmrak/projects/.venv_satellite/bin/pip *)",
"Bash(find *)",
"Bash(ls *)",
"Bash(git commit *)",
"Bash(git push)"
]
},
"$version": 4
}
-47
View File
@@ -1,47 +0,0 @@
# Changelog
## 1.4.1
- Send `played` event to server
## 1.4.0
- Migrate to pyproject.toml
## 1.3.1
- Send audio-start to wake service after detect
## 1.3.0
- Bump to wyoming 1.5.4 (timers)
- Add support for voice timers
- Add `--timer-finished-wav` and `--timer-finished-wav-repeat`
- Add `--timer-started-command`
- Add `--timer-updated-command`
- Add `--timer-cancelled-command`
- Add `--timer-finished-command`
## 1.2.0
- Add `--tts-played-command`
- Add `--mic-seconds-to-mute-after-awake-wav` and `--mic-no-mute-during-awake-wav`
- Send preferred sound format to server
## 1.1.1
- Bump to wyoming 1.5.2 (package fix)
## 1.1.0
- Bump to wyoming 1.5.1
- Send wyoming-satellite version in `info` message
- Ping server if supported (faster awareness of disconnection)
- Support `pause-satellite` message
- Stop streaming/wake word detection as soon as `pause-satellite` is received or server disconnects
- Mute microphone when awake WAV is playing
## 1.0.0
- Initial release
-203
View File
@@ -1,203 +0,0 @@
# Руководство по развёртыванию
Быстрый старт для развёртывания Wyoming Satellite.
## Быстрый старт
### 1. Клонирование и установка
```bash
git clone <repository-url> ~/projects/Satellite
cd ~/projects/Satellite
# Создание виртуального окружения
python3 -m venv ~/.venv_satellite
source ~/.venv_satellite/bin/activate
# Установка зависимостей
pip install -r requirements.txt
# Установка пакета в режиме разработки (для доступа к wyoming_satellite.xvf_usb)
pip install -e .
```
### 2. Установка systemd сервисов
```bash
sudo ./install_systemd_services.sh
```
**Важно:** После установки пакета в режиме editable (`pip install -e .`), новый модуль `wyoming_satellite.xvf_usb` будет автоматически доступен. Если вы добавляете новые модули в `wyoming_satellite/`, убедитесь, что они видны:
```bash
# Проверка доступности модуля
source ~/.venv_satellite/bin/activate
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; print('OK')"
```
### 3. Запуск сервисов
```bash
# LED управление (опционально, требуется ReSpeaker)
sudo systemctl enable --now wyoming-event.service
# Wyoming Satellite
sudo systemctl enable --now wyoming-satelite-vosk.service
# Детекция wake word (Vosk)
sudo systemctl enable --now wyoming-wakeword-vosk.service
```
### 4. Проверка
```bash
sudo systemctl status wyoming-satelite-vosk.service
journalctl -u wyoming-satelite-vosk.service -f
```
В Home Assistant: **Настройки → Устройства и сервисы → Найдено → Wyoming Satellite → Настроить**
---
## Полная документация
| Руководство | Описание |
|-------------|----------|
| [**systemd сервисы**](docs/deployment/systemd-services.md) | Детальная настройка сервисов |
| [**Tutorials**](docs/tutorials/) | Пошаговые руководства для разного оборудования |
| [**Архитектура**](docs/architecture/overview.md) | Как работает Wyoming Satellite |
| [**MQTT LED**](docs/integration/mqtt-led.md) | LED управление с Home Assistant |
| [**Настройки**](docs/settings-cheatsheet.md) | Шпаргалка по всем CLI флагам |
### Tutorials
- [**2-MIC HAT**](docs/tutorials/2mic-hat.md) — Raspberry Pi + ReSpeaker 2-MIC HAT
- [**Установщик**](docs/tutorials/installer.md) — Руководство по интерактивному установщику
---
## Требования
- **Python 3.9+**
- **Linux** (протестировано на Raspberry Pi OS)
- **Микрофон** (USB или HAT)
- **Опционально:** ReSpeaker XVF3800 USB 4-MIC Array (для LED управления)
---
## Обзор сервисов
| Сервис | Порт | Описание |
|--------|------|----------|
| `wyoming-satelite-vosk.service` | 10700 | Основной Wyoming спутник |
| `wyoming-wakeword-vosk.service` | 10400 | Детекция wake word (Vosk) |
| `wyoming-event.service` | 10500 | LED управление + MQTT события |
---
## LED управление (ReSpeaker XVF3800)
Сервис `wyoming-event.service` использует прямой USB доступ к XVF3800 через модуль `wyoming_satellite.xvf_usb`.
### Требования
- **ReSpeaker XVF3800 USB 4-MIC Array** (VID=0x2886, PID=0x0018 или 0x001A)
- **udev правила** для доступа к USB:
```bash
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
sudo udevadm trigger
```
### Проверка подключения
```bash
# Проверка USB устройства
lsusb | grep -i 2886
# Тест контроллера
source ~/.venv_satellite/bin/activate
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; c = XVF3800Controller(); print('OK' if c.is_connected() else 'Not found')"
```
### Настройка яркости
Яркость для каждого состояния настраивается в `examples/led_brightness_config.json`:
```json
{
"idle": 80,
"listening": 120,
"wake": 200,
"thinking": 150,
"speaking": 140,
"paused": 40,
"error": 255,
"global_multiplier": 1.0
}
```
Файл создаётся автоматически при первом запуске. Исключите его из git tracking:
```bash
git update-index --assume-unchanged examples/led_brightness_config.json
```
### Диагностика LED сервиса
```bash
# Статус сервиса
sudo systemctl status wyoming-event.service
# Логи
journalctl -u wyoming-event.service -f
# Тест вручную
source ~/.venv_satellite/bin/activate
cd ~/projects/Satellite/examples
python3 wyoming-led.py --debug
```
### Преимущества прямого USB доступа
Начиная с версии 2026.05, `wyoming-led.py` использует прямой USB доступ вместо subprocess вызовов:
- ✅ **Быстрее:** ~1ms на команду (нет накладных расходов subprocess)
- ✅ **Надёжнее:** контроль USB подключения с авто-переподключением
- ✅ **Чище:** нет процессов subprocess и временных файлов
Подробности в [документации MQTT LED](docs/integration/mqtt-led.md).
---
## Конфигурация
Файлы сервисов используют плейсхолдеры, которые автоматически заменяются при установке:
| Плейсхолдер | Описание |
|-------------|----------|
| `__SAT_BASE_DIR__` | Путь к репозиторию Satellite |
| `__SAT_VENV__` | Путь к виртуальному окружению |
| `__CURRENT_USER__` | Имя текущего пользователя |
| `__HOME_DIR__` | Домашняя директория пользователя |
---
## Wake words (русские)
Отредактируйте `start_satellite.sh`:
```bash
--wake-word-name "привет дроид"
--wake-word-name "привет придурок"
--wake-word-name "привет жопа с ушами"
```
---
## Внешние ресурсы
- [Wyoming Protocol](https://github.com/rhasspy/wyoming)
- [Home Assistant Integration](https://www.home-assistant.io/integrations/wyoming)
- [Vosk](https://alphacephei.com/vosk/)
- [ReSpeaker 4-MIC Array](https://github.com/respeaker/usb_4mic_array)
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Michael Hansen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-190
View File
@@ -1,190 +0,0 @@
# VoskWyoming Satellite — Project Context
## Project Overview
**VoskWyoming Satellite** is a remote voice satellite implementation using the [Wyoming protocol](https://github.com/rhasspy/wyoming) for Home Assistant voice control. It provides:
- **Home Assistant integration** via Wyoming protocol
- **Local wake word detection** using Vosk (Russian wake words: "привет дроид", etc.)
- **Audio enhancements** via WebRTC noise suppression and gain control
- **MQTT integration** for LED control (ReSpeaker XVF3800 4-MIC array) and Home Assistant discovery
- **Voice Activity Detection (VAD)** using Silero or WebRTC
**Key technologies:** Python 3.9+, Wyoming protocol, Vosk, WebRTC, MQTT, systemd
---
## Building and Running
### Prerequisites
```bash
sudo apt-get install python3-venv python3-pip alsa-utils
```
### Setup
```bash
# Clone and setup
cd ~/projects/Satellite
python3 -m venv ~/.venv_satellite
source ~/.venv_satellite/bin/activate
pip install -r requirements.txt
```
### Run (Development)
```bash
# From project root
./start_satellite.sh # Uses systemd-style config
./start-dev.sh # Development mode
python3 -m wyoming_satellite # Direct module run
```
**Main script:** `wyoming_satellite/__main__.py` — argparse-based CLI with extensive options for mic, sound, wake word, VAD, and event handling.
### Run (Production — systemd)
```bash
sudo ./install_systemd_services.sh # Auto-installs services
# Start services
sudo systemctl enable --now wyoming-event.service
sudo systemctl enable --now wyoming-satelite-vosk.service
sudo systemctl enable --now wyoming-wakeword-vosk.service
# Check status
journalctl -u wyoming-satelite-vosk.service -f
```
### Services
| Service | Port | Description |
|---------|------|-------------|
| `wyoming-satelite-vosk.service` | 10700 | Main Wyoming satellite |
| `wyoming-wakeword-vosk.service` | 10400 | Vosk wake word detection |
| `wyoming-event.service` | 10500 | LED control + MQTT events |
---
## Development
### Code Quality
```bash
# Linting
pylint wyoming_satellite/
mypy wyoming_satellite/
# Formatting
black wyoming_satellite/
isort wyoming_satellite/
# Tests
pytest tests/
tox # Multi-version testing
```
**Config files:**
- `pylintrc` — Pylint with many disabled checks (pragmatic approach)
- `mypy.ini` — Permissive, ignores missing imports
- `.isort.cfg` — Import sorting
- `tox.ini` — pytest-based test runner for Python 3.9–3.13
### Project Structure
```
Satellite/
├── wyoming_satellite/ # Main package
│ ├── __main__.py # CLI entry point
│ ├── satellite.py # Satellite implementations
│ ├── settings.py # Dataclass-based settings
│ ├── event_handler.py # Wyoming event handling
│ ├── vad.py # Voice activity detection
│ └── webrtc.py # WebRTC audio processing
├── services/ # systemd service files
├── examples/ # Example scripts (LED, MQTT)
├── scripts/ # Helper scripts
├── tests/ # pytest tests
├── sounds/ # Audio files (awake.wav, etc.)
├── start_satellite.sh # Main startup script
├── install_systemd_services.sh # Service installer
└── requirements.txt # Dependencies
```
### Key Files
| File | Purpose |
|------|---------|
| `wyoming_satellite/__main__.py` | Main entry point, CLI parsing |
| `wyoming_satellite/satellite.py` | `SatelliteBase`, `AlwaysStreamingSatellite`, `VadStreamingSatellite`, `WakeStreamingSatellite` |
| `wyoming_satellite/settings.py` | Settings dataclasses (`MicSettings`, `SndSettings`, `VadSettings`, `WakeSettings`, etc.) |
| `start_satellite.sh` | Production startup script (wake/vad modes) |
| `services/*.service` | systemd unit files with placeholders |
---
## Configuration
### Wake Words (Russian)
Configured in `start_satellite.sh`:
```bash
--wake-word-name "привет дроид"
--wake-word-name "привет придурок"
--wake-word-name "привет жопа с ушами"
```
### VAD Settings
```yaml
# local/vosk-wake-dev/wyoming_vosk_wake/config.yaml
vad:
vad_enabled: true
energy_threshold: 800
speech_threshold: 0.5
min_speech_duration_ms: 200
```
### MQTT (LED Control)
Topics in `examples/wyoming-led.py`:
- `wyoming/led/command`
- `wyoming/led/brightness/set`
- `wyoming/led/brightness/state`
- `wyoming/led/state`
- `wyoming/led/availability`
---
## Testing Practices
- **Framework:** pytest + pytest-asyncio
- **Test files:** `tests/test_satellite.py`, `tests/test_wake_streaming.py`
- **Shared fixtures:** `tests/shared.py`
- **Multi-version:** tox runs tests on Python 3.9–3.13
---
## Deployment Notes
- **Virtual env location:** `~/.venv_satellite` or `~/projects/.venv_satellite`
- **Service installer:** `install_systemd_services.sh` auto-detects venv path and substitutes placeholders (`__SAT_BASE_DIR__`, `__SAT_VENV__`, `__CURRENT_USER__`)
- **Wrapper script:** `satellite-python` installed to `/usr/local/bin/` for systemd services
---
## External Resources
- [Wyoming Protocol](https://github.com/rhasspy/wyoming)
- [Home Assistant Wyoming Integration](https://www.home-assistant.io/integrations/wyoming)
- [Vosk](https://alphacephei.com/vosk/)
- [ReSpeaker 4-MIC Array](https://github.com/respeaker/usb_4mic_array)
---
## Known Issues / Workarounds
- **pylintrc:** Many checks disabled — pragmatic approach, focus on functionality over strict linting
- **mypy:** `ignore_missing_imports = true` — third-party stubs not always available
- **Audio:** Uses `arecord`/`aplay` with specific hardware (`plughw:CARD=Array,DEV=0`) — adjust for different microphones
Executable → Regular
+2 -156
View File
@@ -1,157 +1,3 @@
# README — VoskWyoming Satellite # Satellite
Удалённый голосовой спутник, использующий [Wyoming protocol](https://github.com/rhasspy/wyoming) для Home Assistant. Спутник на RPI3 wakeWords
[![An open standard from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-open-standard.png)](https://www.openhomefoundation.org/)
## Возможности
- 🏠 **Интеграция с Home Assistant** — Нативная поддержка Wyoming протокола
- 🎤 **Локальная детекция wake word** — Vosk, русские фразы ("привет дроид" и др.)
- 🔇 **Аудио улучшения** — Шумоподавление WebRTC и автоматическое усиление
- 💡 **MQTT LED управление** — ReSpeaker XVF3800 4-MIC с авто-обнаружением HA
- 🎯 **Voice Activity Detection** — Silero или WebRTC VAD для приватной работы
## Быстрый старт
### 1. Установка
```bash
git clone <repository-url> ~/projects/Satellite
cd ~/projects/Satellite
python3 -m venv ~/.venv_satellite
source ~/.venv_satellite/bin/activate
pip install -r requirements.txt
```
### 2. Установка systemd сервисов
```bash
sudo ./install_systemd_services.sh
```
### 3. Запуск
```bash
sudo systemctl enable --now wyoming-satelite-vosk.service
sudo systemctl status wyoming-satelite-vosk.service
```
В Home Assistant: **Настройки → Устройства и сервисы → Найдено → Wyoming Satellite → Настроить**
---
## Документация
| Руководство | Описание |
|-------------|----------|
| [**Развёртывание**](DEPLOY.md) | Полное руководство по развёртыванию |
| [**Tutorials**](docs/tutorials/) | Пошаговые руководства для разного оборудования |
| [**Архитектура**](docs/architecture/overview.md) | Как работает Wyoming Satellite |
| [**MQTT LED**](docs/integration/mqtt-led.md) | LED управление с Home Assistant |
| [**VAD/Wake интеграция**](docs/integration/vad-wakeword.md) | Техническое руководство |
| [**Настройки**](docs/settings-cheatsheet.md) | Шпаргалка по CLI флагам |
### Tutorials
- [**2-MIC HAT**](docs/tutorials/2mic-hat.md) — Raspberry Pi + ReSpeaker 2-MIC HAT
- [**Установщик**](docs/tutorials/installer.md) — Интерактивный установщик
---
## Требования
- **Python 3.9+**
- **Linux** (протестировано на Raspberry Pi OS)
- **Микрофон** (USB или HAT)
- **Опционально:** ReSpeaker XVF3800 USB 4-MIC Array (для LED управления)
---
## Структура проекта
```
Satellite/
├── wyoming_satellite/ # Основной Python пакет
├── examples/ # Примеры скриптов (LED, MQTT, конфиги)
├── services/ # systemd сервис файлы
├── docs/ # Документация
│ ├── tutorials/ # Руководства по оборудованию
│ ├── integration/ # Технические руководства
│ ├── deployment/ # Руководства по развёртыванию
│ └── architecture/ # Документация по архитектуре
├── sounds/ # Аудио файлы (awake.wav и др.)
├── scripts/ # Вспомогательные скрипты
└── tests/ # Юнит тесты
```
---
## Конфигурация
### Wake words (русские)
Отредактируйте `start_satellite.sh`:
```bash
--wake-word-name "привет дроид"
--wake-word-name "привет придурок"
--wake-word-name "привет жопа с ушами"
```
### Аудио настройки
```bash
--mic-noise-suppression 2
--mic-auto-gain 5
```
### VAD настройки
```bash
--vad
--vad-system webrtc
--vad-threshold 0.5
```
---
## Разработка
```bash
# Линтинг
pylint wyoming_satellite/
mypy wyoming_satellite/
# Тесты
pytest tests/
# Форматирование
black wyoming_satellite/
isort wyoming_satellite/
```
---
## Видео уроки
- [Wyoming Voice Satellite с ChatGPT](https://www.youtube.com/watch?v=eTKgc0YDCwE)
- [Локальный голосовой ассистент с ChatGPT](https://www.youtube.com/watch?v=pAKqKTkx5X4)
---
## Внешние ресурсы
- [Wyoming Protocol](https://github.com/rhasspy/wyoming)
- [Home Assistant Integration](https://www.home-assistant.io/integrations/wyoming)
- [Vosk](https://alphacephei.com/vosk/)
- [ReSpeaker 4-MIC Array](https://github.com/respeaker/usb_4mic_array)
---
## License
Этот проект является частью экосистемы Open Home Foundation.
[![An open standard from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-open-standard.png)](https://www.openhomefoundation.org/)
-37
View File
@@ -1,37 +0,0 @@
# Документация Wyoming Satellite
## 📖 Для пользователей
| Руководство | Описание |
|-------------|----------|
| [**Развёртывание**](../../DEPLOY.md) | Быстрый старт и systemd сервисы |
| [**2-MIC HAT**](tutorials/2mic-hat.md) | Raspberry Pi + ReSpeaker 2-MIC HAT |
| [**Установщик**](tutorials/installer.md) | Интерактивный установщик |
## 🔧 Для разработчиков
| Документ | Описание |
|----------|----------|
| [**Шпаргалка по настройкам**](settings-cheatsheet.md) | Все CLI флаги и опции |
| [**Архитектура**](architecture/overview.md) | Как работает Wyoming Satellite |
| [**Интеграция VAD/Wake**](integration/vad-wakeword.md) | Техническое руководство по интеграции |
| [**MQTT LED управление**](integration/mqtt-led.md) | LED управление с Home Assistant |
| [**systemd сервисы**](deployment/systemd-services.md) | Детали настройки сервисов |
## 📁 Структура
```
docs/
├── README.md # Этот файл
├── settings-cheatsheet.md # Шпаргалка по настройкам
├── tutorials/
│ ├── 2mic-hat.md # 2-MIC HAT настройка
│ └── installer.md # Интерактивный установщик
├── integration/
│ ├── vad-wakeword.md # Техническая интеграция VAD/Wake
│ └── mqtt-led.md # LED/MQTT интеграция
├── deployment/
│ └── systemd-services.md # systemd конфигурация
└── architecture/
└── overview.md # Обзор архитектуры
```
-183
View File
@@ -1,183 +0,0 @@
# Архитектура Wyoming Satellite
Обзор архитектуры Wyoming Satellite.
## Основные компоненты
```
┌─────────────────────────────────────────────────────────────┐
│ Home Assistant │
│ (Wyoming интеграция) │
└──────────────────────┬──────────────────────────────────────┘
│ Wyoming Protocol (TCP)
│ Порт 10700
┌──────────────────────▼──────────────────────────────────────┐
│ Wyoming Satellite │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ SatelliteBase │ │
│ │ ├─ AlwaysStreamingSatellite │ │
│ │ ├─ VadStreamingSatellite │ │
│ │ └─ WakeStreamingSatellite │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ MicSettings │ │ SndSettings │ │ WakeSettings │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ EventHandler │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────┬─────────────────────┬───────────────────────────┘
│ │
│ Audio In │ Audio Out
│ (arecord) │ (aplay)
┌──────────▼──────┐ ┌────────▼──────────┐
│ Микрофон │ │ Динамик │
│ USB/HAT │ │ USB/HAT │
└─────────────────┘ └───────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Локальные сервисы (опционально) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Wake Word Service │ │ Event Service │ │
│ │ (Vosk, порт 10400) │ │ (MQTT, порт 10500) │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Режимы работы спутника
### AlwaysStreamingSatellite
Постоянно передаёт аудио в Home Assistant.
- **Преимущества:** Минимальная задержка, простота
- **Недостатки:** Высокий трафик, нет локальной обработки
- **Применение:** Быстрая LAN, нет требований к приватности
### VadStreamingSatellite
Использует Voice Activity Detection для начала стриминга только при обнаружении речи.
- **VAD системы:**
- **Silero:** Нейросетевой, более точный
- **WebRTC:** Лёгкий, быстрый
- **Возможности:**
- Пре-буфер (отправляет аудио до триггера VAD)
- Таймаут wake word (ожидание фразы после VAD)
- **Применение:** Приватность, ограничение трафика
### WakeStreamingSatellite
Использует локальную детекцию wake word перед стримингом.
- **Системы wake word:**
- **Vosk:** Грамматика (русские фразы)
- **openWakeWord:** Нейросеть
- **Porcupine:** Коммерческий KWS
- **Поток:**
1. Постоянное прослушивание wake word
2. При обнаружении → стриминг в HA
3. Обработка команды → остановка стриминга
- **Применение:** Полная локальная активация, стиль "Hey Google"
## Аудио пайплайн
```
Микрофон
│
▼
┌─────────────────┐
│ Захват аудио │ arecord -r 16000 -c 1 -f S16_LE
└────────┬────────┘
│
▼
┌─────────────────┐
│ Предобработка │ Громкость, AGC, шумоподавление
└────────┬────────┘
│
▼
┌─────────────────┐
│ VAD / Wake │ Решение: стримить или нет?
└────────┬────────┘
│
▼
┌─────────────────┐
│ Wyoming Protocol│ Кодирование в Wyoming сообщения
└────────┬────────┘
│
▼
Home Assistant
```
## Основные файлы
| Файл | Назначение |
|------|------------|
| `wyoming_satellite/__main__.py` | CLI entry point, парсинг аргументов |
| `wyoming_satellite/satellite.py` | Реализации спутников |
| `wyoming_satellite/settings.py` | Dataclass настроек |
| `wyoming_satellite/event_handler.py` | Обработка Wyoming событий |
| `wyoming_satellite/vad.py` | VAD реализации (Silero, WebRTC) |
| `wyoming_satellite/webrtc.py` | WebRTC обработка аудио |
## Конфигурация
Основная конфигурация через CLI аргументы (см. `__main__.py`):
```bash
# Микрофон
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw'
--mic-noise-suppression 2
--mic-auto-gain 5
# Wake word
--wake-uri tcp://127.0.0.1:10400
--wake-word-name "привет дроид"
# VAD
--vad
--vad-system webrtc
--vad-threshold 0.5
# События (LED, MQTT)
--event-uri tcp://127.0.0.1:10500
```
## Машина состояний
```
┌──────────────┐
│ Остановка │
└──────┬───────┘
│ Старт
▼
┌──────────────┐
┌────────│ Прослушивание│────────┐
│ └──────┬───────┘ │
│ │ │
VAD триггер Wake детекция Стриминг
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ VAD │ │ Wake │ │Стриминг │
│ Активен │ │ Активен │ │ Аудио │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┴───────────────┘
│
AudioStop
▼
┌──────────────┐
│ Прослушивание│
└──────────────┘
```
---
## 🔗 Связанное
- [Настройки (cheat sheet)](settings-cheatsheet.md)
- [Руководство по развёртыванию](../../DEPLOY.md)
- [Интеграция VAD/Wake](integration/vad-wakeword.md)
-274
View File
@@ -1,274 +0,0 @@
# Настройка systemd сервисов
Подробное руководство по настройке и управлению systemd сервисами Wyoming Satellite.
## Расположение файлов сервисов
Файлы сервисов находятся в директории `services/`:
```
services/
├── wyoming-event.service # LED управление + MQTT
├── wyoming-satelite-vosk.service # Основной спутник
└── wyoming-wakeword-vosk.service # Детекция wake word
```
## Скрипт установки
Скрипт `install_systemd_services.sh` автоматически:
1. Определяет путь к виртуальному окружению
2. Определяет текущего пользователя
3. Подставляет плейсхолдеры в файлы сервисов
4. Копирует сервисы в `/etc/systemd/system/`
5. Устанавливает wrapper `satellite-python` в `/usr/local/bin/`
### Ручная установка
Если скрипт не работает, установите вручную:
```bash
# 1. Отредактируйте файлы сервисов, заменив плейсхолдеры:
# __SAT_BASE_DIR__ → /home/sirmrak/projects/Satellite
# __SAT_VENV__ → /home/sirmrak/.venv_satellite
# __CURRENT_USER__ → sirmrak
# 2. Скопируйте в директорию systemd
sudo cp services/*.service /etc/systemd/system/
# 3. Перезагрузите systemd
sudo systemctl daemon-reload
# 4. Включите и запустите
sudo systemctl enable --now wyoming-satelite-vosk.service
```
## Детали сервисов
### wyoming-satelite-vosk.service
Основной сервис Wyoming Satellite. Подключается к:
- Микрофону (arecord)
- Динамику (aplay)
- Сервису wake word (опционально, порт 10400)
- Сервису событий (опционально, порт 10500)
**Порты:** 10700 (Wyoming protocol)
**Зависимости:** network.target
### wyoming-wakeword-vosk.service
Сервис детекции wake word на основе Vosk. Слушает русские фразы:
- "привет дроид"
- "привет придурок"
- "привет жопа с ушами"
**Порты:** 10400 (Wyoming protocol)
**Конфигурация:** `local/vosk-wake-dev/wyoming_vosk_wake/config.yaml`
### wyoming-event.service
LED управление и MQTT интеграция для ReSpeaker XVF3800.
**Порты:** 10500 (Wyoming event protocol)
**Требования:**
- ReSpeaker XVF3800 USB 4-MIC Array
- udev правила: `SUBSYSTEM=="usb", ATTR{idVendor}=="2886", MODE="0666"`
**USB контроллер:** Использует модуль `wyoming_satellite.xvf_usb` для прямого доступа к LED без subprocess.
**MQTT топики:**
- `wyoming/led/command` — Команды от Home Assistant
- `wyoming/led/brightness/set` — Установка яркости (0-255)
- `wyoming/led/brightness/state` — Текущая яркость
- `wyoming/led/state` — Состояние LED
- `wyoming/led/availability` — Статус онлайн/офлайн
**Конфигурация яркости:** `examples/led_brightness_config.json` (создаётся автоматически)
**Диагностика:**
```bash
# Проверка статуса
sudo systemctl status wyoming-event.service
# Логи
journalctl -u wyoming-event.service -f
# Тест USB контроллера
source ~/.venv_satellite/bin/activate
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; print('OK' if XVF3800Controller().is_connected() else 'Not found')"
```
Подробности в [MQTT LED документации](../integration/mqtt-led.md).
## Команды управления
```bash
# Проверка статуса
sudo systemctl status wyoming-satelite-vosk.service
# Просмотр логов (реальное время)
journalctl -u wyoming-satelite-vosk.service -f
# Перезапуск сервиса
sudo systemctl restart wyoming-satelite-vosk.service
# Отключение сервиса
sudo systemctl disable --now wyoming-satelite-vosk.service
# Перезагрузка systemd после изменений
sudo systemctl daemon-reload
```
## Диагностика
### Сервис не запускается
```bash
# Проверка статуса и последних ошибок
sudo systemctl status wyoming-satelite-vosk.service
# Просмотр полных логов
journalctl -u wyoming-satelite-vosk.service -n 100
# Проверка пути к Python
which satellite-python
satellite-python --version
```
### Частые проблемы
| Проблема | Решение |
|----------|---------|
| `ModuleNotFoundError` | Проверьте путь к venv в файле сервиса |
| `arecord: command not found` | Установите `alsa-utils` |
| Permission denied | Проверьте директиву `User=` |
| Порт уже используется | Проверьте, не запущен ли другой экземпляр |
## Wrapper скрипт
Wrapper `satellite-python` позволяет запускать Python из venv без указания полного пути:
```bash
# Установлен в /usr/local/bin/satellite-python
satellite-python --version # Использует Python из venv
```
Используется в файлах сервисов:
```ini
ExecStart=satellite-python -m wyoming_satellite ...
```
---
## 🔗 Связанное
- [Руководство по развёртыванию](../../DEPLOY.md)
- [MQTT LED интеграция](integration/mqtt-led.md)
---
## TEST сервисы (для разработки)
Для тестирования новых функций без влияния на production службы создан **TEST satellite**.
### Отличия от production
| Сервис | Production | Test |
|--------|------------|------|
| Satellite | порт 10700 | порт 10702 + WebSocket :8675 + Audio Level |
| Wake Word | порт 10400 | production сервис |
| Event | порт 10500 | production сервис |
**Важно:** TEST satellite использует production сервисы для wake word и event!
### Вариант 1: Установка TEST сервиса (рекомендуется)
**Установка:**
```bash
cd ~/projects/satellite-dev
sudo ./install_test_services.sh
```
**Запуск:**
```bash
# Убедись что production сервисы запущены
sudo systemctl status wyoming-wakeword-vosk.service
sudo systemctl status wyoming-event.service
# Запуск test satellite
sudo systemctl start wyoming-satelite-vosk-test.service
# Проверка статуса
sudo systemctl status wyoming-satelite-vosk-test.service
# Логи
journalctl -u wyoming-satelite-vosk-test.service -f
```
**Преимущества:**
- ✅ Автоматический перезапуск при ошибке
- ✅ Production службы не затрагиваются
- ✅ Легко переключаться между test/prod
### Вариант 2: Быстрый запуск в tmux
**Требования:** `tmux` установлен (`sudo apt-get install tmux`)
**Запуск:**
```bash
cd ~/projects/satellite-dev
./services/test/start_test.sh
```
**Управление:**
```bash
# Подключиться к сессии
tmux attach -t satellite-test
# Остановить
tmux kill-session -t satellite-test
```
**Важно:** Production сервисы должны быть запущены отдельно!
### Тестирование WebSocket
**Проверка подключения:**
```bash
cd ~/projects/satellite-dev
source .venv/bin/activate
# Python тест клиент
python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675
# Или браузер
# Откройте examples/websocket-service/client.html
```
**Проверка audio level:**
```javascript
// В browser console (client.html)
// Уровень микрофона должен обновляться 10 раз в секунду
// {"type": "audio-level", "data": {"level": 0.42}}
```
### Остановка TEST сервисов
```bash
# systemd сервис
sudo systemctl stop wyoming-satelite-vosk-test.service
# tmux сессия
tmux kill-session -t satellite-test
```
-79
View File
@@ -1,79 +0,0 @@
# План: WebSocket сервер для Wyoming Satellite
**Дата:** 2026-05-20
**Статус:** 🔄 Тестирование
**Ветка:** `satellite-dev`
---
## Что сделано
| Этап | Что | Статус |
|------|-----|--------|
| WS-1 | `settings.py` — поля WebSocket в EventSettings | ✅ |
| WS-2 | `satellite.py` — сервер, broadcast, команды | ✅ |
| WS-2A | Протокол команд (7 команд) | ✅ |
| WS-3 | `__main__.py` — CLI аргументы | ✅ |
| WS-4 | Audio level (RMS) мониторинг | ✅ |
| WS-4 | Audio recording | ✅ |
## Что осталось
| Этап | Что | Статус |
|------|-----|--------|
| WS-5 | Обновить `docs/integration/mqtt-led.md` | ⏳ |
| — | Merge в master | ⏳ |
## Чеклист тестирования (WS-4)
- [ ] Satellite запускается с `--websocket-enabled`
- [ ] Браузер подключается к порту 8675
- [ ] События приходят (`synthesize`, `audio-start/stop`, `played`)
- [x] `audio-level` приходит ~10 раз/сек
- [x] Команды работают: `set_volume`, `set_mic_volume`, `mute/unmute`
- [ ] Команды работают: `play_sound`, `set_led_brightness`
- [x] `start_recording` / `stop_recording` — возвращают WAV
- [ ] Home Assistant работает (порт 10700) — не сломалось
- [ ] wyoming-led.py работает (порт 10500) — не сломалось
- [ ] При отключении клиента satellite не падает
---
## Архитектура (справка)
```
Home Assistant ──10700──► Satellite
├── Event CLIENT :10500 → LED, MQTT
└── WebSocket SERVER :8675 ← Browser
```
## Протокол команд (справка)
| Команда | Данные | Описание |
|---------|--------|----------|
| `set_volume` | `{"level": 0.0-1.0}` | Громкость TTS |
| `set_mic_volume` | `{"level": 0.0-2.0}` | Чувствительность микрофона |
| `mute` / `unmute` | `{}` | Заглушить/включить микрофон |
| `play_sound` | `{"path": "/path.wav"}` | Воспроизвести WAV |
| `set_led_brightness` | `{"brightness": 0-255}` | Яркость LED |
| `start_recording` | `{}` | Начать запись с микрофона |
| `stop_recording` | `{}` | Остановить, вернуть WAV (base64) |
| `trigger_event` | `{"event_type": "...", "data": {...}}` | Отправить событие в HA |
## Запуск
```bash
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-uri tcp://127.0.0.1:10500 \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled
```
## Известные проблемы
- **ALSA card hardcoded** — `-c 2` в `WakeStreamingSatellite` (см. `satellite.py` ~1700)
- **`_kill_aplay_processes()`** — может убить сторонние aplay процессы
- **Recording buffer не ограничен** — длительная запись ест память
- **`trigger_event`** — создаёт сырой `Event`, без валидации Wyoming
-202
View File
@@ -1,202 +0,0 @@
# MQTT LED Управление
Руководство по интеграции LED управления ReSpeaker через MQTT и Home Assistant.
## Обзор
LED сервис (`examples/wyoming-led.py`) предоставляет:
- **Light сущность** — Управление яркостью и цветом LED
- **Sensor сущность** — Состояние спутника (ожидание, слушание, говорение)
- **Number сущности** — 7 слайдеров для точной настройки
- **MQTT Discovery** — Авто-конфигурация в Home Assistant
## Архитектура
```
┌──────────────────┐ MQTT ┌──────────────────┐
│ Home Assistant │◄─────────────►│ wyoming-led.py │
│ │ │ (порт 10501) │
└──────────────────┘ └────────┬─────────┘
│
│ Wyoming Events
│ (порт 10500)
┌────────▼─────────┐
│ Satellite │
│ (порт 10700) │
└──────────────────┘
```
## Установка
### 1. Установка зависимостей
```bash
cd ~/projects/Satellite
source ~/.venv_satellite/bin/activate
pip install paho-mqtt
```
### 2. Настройка MQTT
Отредактируйте `examples/wyoming-led.py`:
```python
MQTT_BROKER = "192.168.200.100" # Ваш MQTT брокер
MQTT_PORT = 1883
MQTT_USER = "mqtt"
MQTT_PASS = "mqtt"
```
### 3. Создание systemd сервиса
```bash
sudo nano /etc/systemd/system/wyoming-led.service
```
```ini
[Unit]
Description=Wyoming LED Service с MQTT
After=network.target mosquitto.service
[Service]
Type=simple
User=sirmrak
WorkingDirectory=/home/sirmrak/projects/Satellite
Environment="PATH=/home/sirmrak/projects/.venv_satellite/bin"
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 /home/sirmrak/projects/Satellite/examples/wyoming-led.py --host 0.0.0.0 --port 10501
Restart=always
[Install]
WantedBy=multi-user.target
```
### 4. Включение сервиса
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now wyoming-led.service
```
## MQTT топики
| Топик | Направление | Описание |
|-------|-------------|----------|
| `wyoming/led/command` | HA → Скрипт | Команды света (вкл/выкл, цвет, яркость) |
| `wyoming/led/brightness/set` | HA → Скрипт | Установка яркости (0-255) |
| `wyoming/led/brightness/state` | Скрипт → HA | Текущая яркость |
| `wyoming/led/state` | Скрипт → HA | Состояние LED (вкл/выкл) |
| `wyoming/led/availability` | Скрипт → HA | Онлайн/офлайн статус |
| `wyoming/satellite/state` | Скрипт → HA | Состояние спутника |
## Сущности Home Assistant
После discovery вы увидите:
- **Light:** `light.wyoming_led` — RGB управление
- **Sensor:** `sensor.wyoming_satellite_state` — Текущее состояние
- **Number:** `number.wyoming_led_brightness` — Слайдер яркости
- **Number:** `number.wyoming_led_red/green/blue` — Слайдеры цвета
- **Number:** `number.wyoming_led_idle/listening/speaking` — Цвета состояний
## Состояния LED
| Состояние | Цвет | Поведение |
|-----------|------|-----------|
| Ожидание (Idle) | Синий (тусклый) | Дыхательный эффект |
| Слушание (Listening) | Голубой | Постоянный |
| Говорение (Speaking) | Зелёный | Постоянный |
| Ошибка (Error) | Красный | Мигание |
## Настройка яркости
Яркость LED хранится в `examples/led_brightness_config.json`:
```json
{
"idle": 50,
"listening": 100,
"speaking": 150,
"error": 200
}
```
Этот файл создаётся автоматически и должен быть исключён из git tracking:
```bash
git update-index --assume-unchanged examples/led_brightness_config.json
```
## Диагностика
### LED не реагирует
```bash
# Проверка статуса сервиса
sudo systemctl status wyoming-led.service
# Просмотр логов
journalctl -u wyoming-led.service -f
# Тест вручную
python3 examples/wyoming-led.py --debug
```
### MQTT подключение не удалось
- Проверьте, что брокер запущен: `sudo systemctl status mosquitto`
- Проверьте учётные данные в `wyoming-led.py`
- Тест подключения: `mosquitto_sub -t 'wyoming/led/#' -v`
### ReSpeaker не обнаружен
```bash
# Проверка USB устройств
lsusb | grep -i 2886
# Тест USB контроллера
source ~/.venv_satellite/bin/activate
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; c = XVF3800Controller(); print('OK' if c.is_connected() else 'Not found')"
```
## Поддерживаемое оборудование
| Устройство | Поддержка | Примечания |
|------------|-----------|------------|
| ReSpeaker XVF3800 (USB 4-MIC) | ✅ Полная | Рекомендуется |
| ReSpeaker 2-MIC HAT | ✅ Полная | Требуется GPIO |
| ReSpeaker 4-MIC HAT | ✅ Полная | Требуется GPIO |
| Обычный USB микрофон | ⚠️ Частичная | Без LED управления |
## Связанные файлы
- `examples/wyoming-led.py` — Основной скрипт LED управления
- `wyoming_satellite/xvf_usb.py` — USB контроллер XVF3800 (прямой доступ без subprocess)
- `examples/led_brightness_config.json` — Настройки яркости
- `services/wyoming-event.service` — Сервис событий (альтернатива)
## Архитектура USB контроллера
Начиная с версии 2026.05, `wyoming-led.py` использует прямой USB доступ через модуль `wyoming_satellite.xvf_usb`:
**Преимущества:**
- ✅ Быстрее: ~1ms на команду (нет накладных расходов subprocess)
- ✅ Надёжнее: контроль USB подключения с авто-переподключением
- ✅ Чище: нет процессов subprocess и временных файлов
**Пример использования:**
```python
from wyoming_satellite.xvf_usb import XVF3800Controller, EFFECT_BREATH, rgb_to_bgr_int
controller = XVF3800Controller()
controller.set_led_effect(EFFECT_BREATH)
controller.set_led_color(rgb_to_bgr_int(0, 188, 212)) # бирюзовый
controller.set_led_brightness(150)
```
---
## 🔗 Связанное
- [Руководство по развёртыванию](../../DEPLOY.md)
- [Настройка systemd сервисов](deployment/systemd-services.md)
-62
View File
@@ -1,62 +0,0 @@
## Интеграция VAD и Wakeword: выводы и план
### 1) Картина текущей архитектуры (Wyoming Satellite)
- **Режимы работы спутника**:
- AlwaysStreaming — всегда стримит аудио, VAD/Wake не участвуют.
- VadStreaming — локальный VAD решает момент старта стриминга; при активации отправляется предречь (pre-roll), опционально действует таймаут на ожидание wakeword.
- WakeStreaming — аудио уходит в локальный wakeword-сервис (Wyoming-протокол); при Detection начинается стриминг на сервер (STT/интенты).
- **VAD по умолчанию**: `SileroVad` с интерфейсом `__call__(audio_bytes|None) -> bool`, поддержка `reset()` через передачу `None`. Предречь хранится в кольцевом буфере, при активации догружается первой.
- **Wakeword**: внешние сервисы по Wyoming (например, openWakeWord, Porcupine, Snowboy), принимают `Audio*` события и отдают `Detection`.
### 2) Что есть в `myproject`
- **VAD** (`myproject/vad_core.py`):
- Использует `webrtcvad.Vad` + энерго-критерии, адаптивный порог, простое шумоподавление и автоусиление.
- Имеет предбуфер и основной буфер записи сегмента речи; по завершении вызывает callback с целым кусочком аудио.
- Ввод аудио сейчас завязан на PyAudio внутри класса, то есть детектор и источник аудио слиты.
- **Wakeword на Vosk** (`myproject/vosk_local_STT.py` + `commands.py`):
- Собирает грамматику из `commands.yaml` (в т.ч. секцию `Wakewords`) и распознаёт текст.
- Факт wakeword определяется как точное совпадение распознанной фразы с элементами из `Wakewords` (то есть post-utterance wakeword через STT).
### 3) Сопоставление и ключевые различия
- В Wyoming VAD — быстрый фрейм-по-фрейм решатель с мгновенным флагом активации, а у вас — сегментатор с внутренним циклом и завершением по тишине.
- В Wyoming wakeword — отдельный потоковый сервис (KWS-подобное поведение), а у вас — определение wakeword как часть STT после завершения фразы.
### 4) Варианты интеграции
1. **Заменить локальный VAD на ваш (рекомендуется этап 2):**
- Выделить из `VADPolling` чистый детектор без PyAudio-ввода (API вида: `process(frame_bytes) -> { started/stopped/speech_detected, prebuffer, ... }`).
- Добавить фабрику VAD в спутник: `settings.vad.system: "silero"|"my_webrtc_vad"`, инициализировать нужную реализацию.
- При активации отдавать предбуфер и стартовать стриминг — совместимо с текущей логикой VadStreaming.
2. **Оформить wakeword на Vosk как Wyoming-сервис (рекомендуется этап 1):**
- Небольшой сервер по Wyoming-протоколу, который:
- подгружает грамматику только из `Wakewords`;
- принимает `AudioStart/AudioChunk/AudioStop` и кормит чанк в `KaldiRecognizer.AcceptWaveform`;
- при совпадении фразы — отправляет `Detection(name=...)` немедленно.
- Подключается как `wake.system: "vosk_wake"`, аналогично `openWakeWord`/`porcupine1`.
### 5) Минимальные изменения кода
- **Для wakeword (этап 1):**
- Реализовать отдельный модуль `vosk_wake` (сервер Wyoming): запуск по TCP, обработка событий, генерация `Detection` по фразам из `Wakewords`.
- Unit-файл/скрипт запуска по аналогии с `local/services/wyoming-wakeword.service` и запись в конфиг `wake.system`.
- **Для VAD (этап 2):**
- Вынести логику детектора из `VADPolling` в отдельный класс без I/O.
- Добавить флаг `vad.system` и фабрику выбора реализации; адаптер, который преобразует `AudioChunk` → фреймы и использует ваш детектор для решения «старт/стоп/предречь».
### 6) Пошаговый план внедрения
1) Wakeword на Vosk (быстрый выигрыш):
- Сделать `vosk_wake` сервис: узкая грамматика = только `Wakewords`, немедленный `Detection` при совпадении.
- Прописать сервис в systemd/скрипте, указать URI и `wake.system`.
- Протестировать в режиме `WakeStreamingSatellite`.
2) Замена VAD:
- Рефакторинг `VADPolling`: отделить детектор от аудио-источника.
- Добавить `vad.system` и внедрить адаптер под текущий интерфейс Wyoming.
- Протестировать в `VadStreamingSatellite` с предбуфером и таймаутом wakeword.
### 7) Риски и примечания
- Vosk как wakeword — это распознавание текста; для «на лету» задержка может быть выше, чем у специализированного KWS. С узкой грамматикой (только wakewords) будет быстрее и устойчивее.
- Ваши улучшения VAD (AGC/NR/адаптивные пороги) полезны; главное — отделить I/O и обеспечить детерминированный фреймовый API, совместимый с Wyoming.
### 8) Резюме
- Этап 1: добавить `vosk_wake` как Wyoming-сервис wakeword — минимум инвазивности и быстрая интеграция.
- Этап 2: внедрить ваш VAD через фабрику и адаптер — получить продвинутую локальную активацию речи, сохранив предречь и таймауты.
-242
View File
@@ -1,242 +0,0 @@
# Wyoming Satellite — Шпаргалка по настройкам
Быстрый справочник всех CLI флагов и настроек.
---
## 🎤 Микрофон (`--mic-*`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--mic-uri` | — | Wyoming microphone сервис URI (`tcp://ip:port`) |
| `--mic-command` | — | Команда для записи с микрофона (`arecord ...`) |
| `--mic-command-rate` | 16000 | Частота дискретизации (Гц) |
| `--mic-command-width` | 2 | Разрядность (байт) |
| `--mic-command-channels` | 1 | Каналы (1=моно) |
| `--mic-command-samples-per-chunk` | 1024 | Сэмплов за чтение |
| `--mic-volume-multiplier` | 1.0 | Множитель громкости |
| `--mic-noise-suppression` | 0 | Шумоподавление (0-4) |
| `--mic-auto-gain` | 0 | Автоусиление (0-31 dbFS) |
| `--mic-channel-index` | None | Индекс канала |
| `--mic-seconds-to-mute-after-awake-wav` | 0.5 | Глушить после awake WAV |
| `--mic-no-mute-during-awake-wav` | false | Не глушить во время awake WAV |
**Пример:**
```bash
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
--mic-noise-suppression 2 \
--mic-auto-gain 5
```
---
## 🔊 Звук (`--snd-*`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--snd-uri` | — | Wyoming sound сервис URI |
| `--snd-command` | — | Команда для вывода звука (`aplay ...`) |
| `--snd-command-rate` | 22050 | Частота дискретизации (Гц) |
| `--snd-command-width` | 2 | Разрядность (байт) |
| `--snd-command-channels` | 1 | Каналы |
| `--snd-volume-multiplier` | 1.0 | Множитель громкости |
| `--awake-wav` | — | WAV после wake word |
| `--done-wav` | — | WAV после команды |
**Пример:**
```bash
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -r 22050 -c 1 -f S16_LE -t raw' \
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
```
---
## 🎯 Wake Word (`--wake-*`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--wake-uri` | — | Wyoming wake word сервис URI |
| `--wake-command` | — | Локальная команда wake word |
| `--wake-word-name` | — | Фраза wake word (можно повторять) |
| `--wake-refractory-seconds` | 5.0 | Задержка между детекциями |
**Пример:**
```bash
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name "привет дроид" \
--wake-word-name "привет придурок"
```
---
## 🎤 Voice Activity Detection (`--vad*`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--vad` | false | Включить VAD |
| `--vad-system` | silero | `silero` или `webrtc` |
| `--vad-threshold` | 0.5 | Порог (0-1) |
| `--vad-trigger-level` | 1 | Чанков для активации |
| `--vad-energy-threshold` | None | Энергия для webrtc (int16) |
| `--vad-buffer-seconds` | 2.0 | Пре-буфер (секунды) |
| `--vad-wake-word-timeout` | 5.0 | Таймаут wake word |
**Пример:**
```bash
--vad \
--vad-system webrtc \
--vad-threshold 0.5 \
--vad-energy-threshold 200 \
--vad-trigger-level 5
```
---
## 📡 Event Service (`--event-uri`, `--*-command`)
| Флаг | Событие |
|------|---------|
| `--event-uri` | Wyoming event сервис URI |
| `--startup-command` | Спутник запущен |
| `--streaming-start-command` | Стриминг начался |
| `--streaming-stop-command` | Стриминг остановлен |
| `--detect-command` | Начало детекции wake |
| `--detection-command` | Wake word обнаружен |
| `--tts-played-command` | Аудио воспроизведено |
| `--transcript-command` | STT текст |
| `--stt-start-command` | STT начался |
| `--stt-stop-command` | STT завершён |
| `--synthesize-command` | TTS текст получен |
| `--tts-start-command` | TTS начался |
| `--tts-stop-command` | TTS завершён |
| `--error-command` | Ошибка |
| `--connected-command` | Подключен к серверу |
| `--disconnected-command` | Отключен от сервера |
**Пример:**
```bash
--event-uri tcp://127.0.0.1:10500 \
--detection-command 'examples/commands/detection.sh' \
--transcript-command 'examples/commands/transcript.sh' \
--tts-start-command 'notify-send "TTS started"'
```
---
## ⏱️ Таймеры (`--timer-*`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--timer-started-command` | — | Таймер запущен |
| `--timer-updated-command` | — | Таймер изменён/пауза/возобновление |
| `--timer-cancelled-command` | — | Таймер отменён |
| `--timer-finished-command` | — | Таймер завершён |
| `--timer-finished-wav` | — | WAV файл для воспроизведения |
| `--timer-finished-wav-repeat` | `1 0` | `<повторы> <задержка_сек>` |
**Пример:**
```bash
--timer-finished-wav /path/to/timer.wav \
--timer-finished-wav-repeat 3 1.0
```
---
## 🛰️ Спутник (`--uri`, `--name`, `--area`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--uri` | **требуется** | URI спутника (`tcp://0.0.0.0:10700`) |
| `--name` | Wyoming Satellite | Имя спутника |
| `--area` | — | Имя зоны (для Home Assistant) |
---
## 🔧 Отладка и обнаружение (`--debug*`, `--zeroconf*`)
| Флаг | По умолчанию | Описание |
|------|--------------|----------|
| `--debug` | false | DEBUG логирование |
| `--debug-recording-dir` | None | Папка для записи аудио |
| `--no-zeroconf` | false | Отключить Zeroconf |
| `--zeroconf-name` | MAC address | Имя для Zeroconf |
| `--zeroconf-host` | Авто | Хост для Zeroconf |
| `--log-format` | BASIC | Формат логов |
---
## 📊 Режимы работы
### Режим 1: Always Streaming (без VAD, без wake)
```bash
python3 -m wyoming_satellite \
--name 'my-satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw'
```
**Применение:** Минимальная задержка, постоянный стриминг в HA.
---
### Режим 2: VAD Streaming (стрим после обнаружения речи)
```bash
python3 -m wyoming_satellite \
--name 'my-satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
--vad \
--vad-system webrtc \
--vad-threshold 0.5 \
--vad-energy-threshold 200
```
**Применение:** Приватность, стрим только при разговоре.
---
### Режим 3: Wake Streaming (локальная детекция wake word)
```bash
python3 -m wyoming_satellite \
--name 'my-satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name "привет дроид" \
--wake-word-name "привет придурок"
```
**Применение:** Стиль "Hey Google", полная локальная активация.
---
### Режим 4: С Event Service (LED, MQTT, уведомления)
```bash
python3 -m wyoming_satellite \
--name 'my-satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name "привет дроид" \
--event-uri tcp://127.0.0.1:10500 \
--detection-command 'examples/commands/detection.sh' \
--transcript-command 'examples/commands/transcript.sh'
```
**Применение:** Полная интеграция с LED, MQTT, внешними скриптами.
---
## 🔗 Связанное
- [Архитектура](architecture/overview.md)
- [Руководство по развёртыванию](../../DEPLOY.md)
- [MQTT LED интеграция](integration/mqtt-led.md)
-404
View File
@@ -1,404 +0,0 @@
# 2-MIC HAT Tutorial
Создание голосового спутника на [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) и [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0).
**Предварительные требования:** См. [Руководство по развёртыванию](../../DEPLOY.md) для базовой установки.
## Установка ОС
Следуйте инструкциям по [установке Raspberry Pi OS](https://www.raspberrypi.com/software/). В разделе "Choose OS" выберите "Raspberry Pi OS (other)" и "Raspberry Pi OS (**64-bit**) Lite".
Когда спросят о применении настроек кастомизации, выберите "Edit Settings" и:
* Установите имя пользователя/пароль
* Настройте беспроводную LAN
* На вкладке Services включите SSH и используйте аутентификацию по паролю
## Установка ПО
После прошивки и загрузки спутника, подключитесь к нему по SSH, используя имя пользователя/пароль, настроенные при прошивке.
**На спутнике**, убедитесь, что системные зависимости установлены:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
git \
python3-venv
```
Склонируйте репозиторий `wyoming-satellite`:
```sh
git clone https://github.com/rhasspy/wyoming-satellite.git
```
Если у вас ReSpeaker 2Mic или 4Mic HAT, перекомпилируйте и установите драйверы (это займёт много времени):
```sh
cd wyoming-satellite/
sudo bash etc/install-respeaker-drivers.sh
```
После установки драйверов необходимо перезагрузить спутник:
```sh
sudo reboot
```
После перезагрузки подключитесь снова по SSH и продолжите установку:
```sh
cd wyoming-satellite/
python3 -m venv .venv
.venv/bin/pip3 install --upgrade pip
.venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install \
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
-e '.[all]'
```
Если установка прошла успешно, вы сможете запустить:
```sh
script/run --help
```
## Определение аудиоустройств
Правильный выбор устройств микрофона/динамика критически важен для работы спутника. В этом разделе мы сделаем тестовую запись и воспроизведение.
Список доступных микрофонов:
```sh
arecord -L
```
Если у вас ReSpeaker 2Mic HAT, вы увидите:
```
plughw:CARD=seeed2micvoicec,DEV=0
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
Hardware device with all software conversions
```
Для других микрофонов предпочитайте устройства, начинающиеся с `plughw:` или используйте `default`, если не знаете, что выбрать.
Запишите 5-секундный образец с выбранного микрофона:
```sh
arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t wav -d 5 test.wav
```
Скажите что-нибудь во время работы `arecord`. Если получили ошибки, попробуйте другое устройство микрофона, изменив `-D <device>`.
Список доступных динамиков:
```sh
aplay -L
```
Если у вас ReSpeaker 2Mic HAT, вы увидите:
```
plughw:CARD=seeed2micvoicec,DEV=0
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
Hardware device with all software conversions
```
Для других динамиков предпочитайте устройства, начинающиеся с `plughw:` или используйте `default`, если не знаете, что выбрать.
Воспроизведите записанный образец WAV:
```sh
aplay -D plughw:CARD=seeed2micvoicec,DEV=0 test.wav
```
Вы должны услышать записанный образец. Если есть проблемы, попробуйте другое устройство динамика, изменив `-D <device>`.
Запомните устройства микрофона и динамика для следующего шага.
## Запуск спутника
В директории `wyoming-satellite` выполните:
```sh
script/run \
--debug \
--name 'my satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
```
Измените `-D <device>` для `arecord` и `aplay` в соответствии с аудиоустройствами из предыдущего раздела.
Вы можете установить `--name <NAME>` на любое значение, но оно должно оставаться одинаковым при каждом запуске спутника.
В Home Assistant проверьте раздел "Devices & services" в настройках. Через некоторое время вы увидите ваш спутник как "Discovered" (Wyoming Protocol). Нажмите кнопку "Configure" и "Submit". Выберите зону, где расположен ваш спутник, и нажмите "Finish".
Ваш спутник должен сказать "Streaming audio", и вы можете использовать wake word предпочитаемого пайплайна.
## Создание сервисов
Вы можете запустить wyoming-satellite как systemd сервис, создав файл сервиса:
```sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Вставьте следующий шаблон и измените `/home/pi` и аргументы `script/run` в соответствии с вашей настройкой:
```text
[Unit]
Description=Wyoming Satellite
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=/home/pi/wyoming-satellite/script/run --name 'my satellite' --uri 'tcp://0.0.0.0:10700' --mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' --snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
WorkingDirectory=/home/pi/wyoming-satellite
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Сохраните файл и выйдите из редактора. Далее включите сервис для автозагрузки и запустите:
```sh
sudo systemctl enable --now wyoming-satellite.service
```
(возможно, вам придётся нажать CTRL+C, чтобы вернуться к командной оболочке)
После запуска сервиса вы можете просматривать логи в реальном времени:
```sh
journalctl -u wyoming-satellite.service -f
```
При необходимости отключите и остановите сервис:
```sh
sudo systemctl disable --now wyoming-satellite.service
```
## Аудио улучшения
Вы можете запустить спутник с автоматическим усилением и шумоподавлением:
```sh
script/run \
... \
--mic-auto-gain 5 \
--mic-noise-suppression 2
```
Автоматическое усиление от 0 до 31 dbFS, где 31 — самое громкое.
Шумоподавление от 0 до 4, где 4 — максимальное подавление (может вызывать искажения аудио).
Вы также можете использовать `--mic-volume-multiplier X` для умножения всех аудиосэмплов на `X`. Например, использование 2 для `X` удвоит громкость микрофона (но может вызвать искажения). Соответствующий `--snd-volume-multiplier` делает то же самое для воспроизведения аудио.
## Локальная детекция Wake Word
Установите необходимые системные зависимости:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
libopenblas-dev
```
Из домашней директории установите Wyoming сервис openWakeWord:
```sh
git clone https://github.com/rhasspy/wyoming-openwakeword.git
cd wyoming-openwakeword
script/setup
```
Создайте systemd сервис для него:
```sh
sudo systemctl edit --force --full wyoming-openwakeword.service
```
Вставьте следующий шаблон и измените `/home/pi` и аргументы `script/run` в соответствии с вашей настройкой:
```text
[Unit]
Description=Wyoming openWakeWord
[Service]
Type=simple
ExecStart=/home/pi/wyoming-openwakeword/script/run --uri 'tcp://127.0.0.1:10400'
WorkingDirectory=/home/pi/wyoming-openwakeword
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Сохраните файл и выйдите из редактора.
Теперь вы можете обновить сервис спутника:
```sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Обновите только следующие части:
```text
[Unit]
...
Requires=wyoming-openwakeword.service
[Service]
...
ExecStart=/home/pi/wyoming-satellite/script/run ... --wake-uri 'tcp://127.0.0.1:10400' --wake-word-name 'ok_nabu'
...
[Install]
...
```
Перезагрузите и перезапустите сервис спутника:
```sh
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satellite.service
```
Вы должны увидеть, что сервис wake загрузился автоматически:
```sh
sudo systemctl status wyoming-satellite.service wyoming-openwakeword.service
```
Все они должны быть "active (running)" и зелёные.
Протестируйте спутник, сказав "ok, nabu" и голосовую команду. Используйте `journalctl` для проверки логов сервисов на наличие ошибок:
```sh
journalctl -u wyoming-openwakeword.service -f
```
Убедитесь, что запускаете `sudo systemctl daemon-reload` каждый раз при внесении изменений в сервис.
## LED сервис
Примеры сервисов событий для ReSpeaker 2Mic и 4Mic HAT включены в `wyoming-satellite/examples`, они изменяют цвет LED в зависимости от состояния спутника. Пример ниже для 2Mic HAT с использованием `2mic_service.py`. Если вы используете 4Mic HAT, используйте `4mic_service.py`, так как светодиоды и GPIO пины немного отличаются.
Установите его из домашней директории:
```sh
cd wyoming-satellite/examples
python3 -m venv --system-site-packages .venv
.venv/bin/pip3 install --upgrade pip
.venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install 'wyoming==1.5.2'
```
Если вы используете ReSpeaker USB 4mic array v2.0, дополнительно установите pixel-ring:
```sh
.venv/bin/pip3 install 'pixel-ring'
```
Аргумент `--system-site-packages` используется для доступа к предустановленным пакетам `gpiozero` и `spidev`. Если они **ещё не установлены** в вашей системе, выполните:
```sh
sudo apt-get install python3-spidev python3-gpiozero
```
Протестируйте сервис:
```sh
.venv/bin/python3 2mic_service.py --help
```
Создайте systemd сервис:
```sh
sudo systemctl edit --force --full 2mic_leds.service
```
Вставьте следующий шаблон и измените `/home/pi` и аргументы `script/run` в соответствии с вашей настройкой:
```text
[Unit]
Description=2Mic LEDs
[Service]
Type=simple
ExecStart=/home/pi/wyoming-satellite/examples/.venv/bin/python3 2mic_service.py --uri 'tcp://127.0.0.1:10500'
WorkingDirectory=/home/pi/wyoming-satellite/examples
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Сохраните файл и выйдите из редактора.
Теперь вы можете обновить сервис спутника:
```sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Обновите только следующие части:
```text
[Unit]
...
Requires=2mic_leds.service
[Service]
...
ExecStart=/home/pi/wyoming-satellite/script/run ... --event-uri 'tcp://127.0.0.1:10500'
...
[Install]
...
```
Перезагрузите и перезапустите сервис спутника:
```sh
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satellite.service
```
Вы должны увидеть, что сервис загрузился автоматически:
```sh
sudo systemctl status wyoming-satellite.service 2mic_leds.service
```
Все они должны быть "active (running)" и зелёные.
Попробуйте голосовую команду и посмотрите, реагируют ли светодиоды. Используйте `journalctl` для проверки логов сервисов на наличие ошибок:
```sh
journalctl -u 2mic_leds.service -f
```
Если возникли проблемы, добавьте аргумент `--debug` в командную строку для увеличения уровня логирования.
Для управления яркостью светодиодов используйте аргумент `--led-brightness`, который принимает целые числа от 1 до 31.
Убедитесь, что запускаете `sudo systemctl daemon-reload` каждый раз при внесении изменений в сервис.
---
## 🔗 Связанное
- [Руководство по развёртыванию](../../DEPLOY.md)
- [Интерактивный установщик](installer.md)
-84
View File
@@ -1,84 +0,0 @@
# Интерактивный установщик
Создание голосового спутника на Raspberry Pi 3+ с USB микрофоном и динамиками.
**Предварительные требования:** См. [Руководство по развёртыванию](../../DEPLOY.md) для базовой установки.
## Установка ОС
Следуйте инструкциям по [установке Raspberry Pi OS](https://www.raspberrypi.com/software/). В разделе "Choose OS" выберите "Raspberry Pi OS (other)" и "Raspberry Pi OS (**64-bit**) Lite".
Когда спросят о применении настроек кастомизации, выберите "Edit Settings" и:
* Установите имя пользователя/пароль
* Настройте беспроводную LAN
* На вкладке Services включите SSH и используйте аутентификацию по паролю
## Установка зависимостей
После прошивки и загрузки спутника, подключитесь к нему по SSH, используя имя пользователя/пароль, настроенные при прошивке.
**На спутнике**, убедитесь, что системные зависимости установлены:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
git \
python3-venv
```
Склонируйте репозиторий `wyoming-satellite`:
```sh
git clone https://github.com/rhasspy/wyoming-satellite.git
```
Теперь вы можете запустить установщик:
```sh
cd wyoming-satellite/
python3 -m installer
```
## Спутник
В меню "Satellite" выберите "Satellite Type" и выберите "Local wake word detection" (пробелом).
## Микрофон
Убедитесь, что USB микрофон подключён.
В меню "Microphone" выберите "Autodetect" и громко поговорите в микрофон в течение нескольких секунд.
В "Audio Settings" установите "Noise Suppression" в "Medium" и "Auto Gain" в 15.
## Динамики
Убедитесь, что USB динамики подключены.
В меню "Speakers" выберите "Test All Speakers". Для каждого устройства выберите "Play Sound" и прослушайте. Если слышите звук, выберите "Choose This Device"; иначе выберите "Next Device".
В "Toggle Feedback Sounds" включите "On wake-up" и "After voice command" (пробелом).
## Wake Word
В меню "Wake Word" выберите "Wake Word System" и выберите "openWakeWord" (пробелом). Когда будет предложено, установите openWakeWord.
В меню "openWakeWord" выберите "Download Community Wake Words". Вернитесь в меню "Wake Word", выберите "Choose Wake Word" и выберите одну фразу (пробелом).
## Применение настроек
В главном меню выберите "Apply Settings" и введите пароль.
Проверьте логи:
```sh
sudo journalctl -f -u wyoming-satellite.service
```
---
## 🔗 Связанное
- [Руководство по развёртыванию](../../DEPLOY.md)
- [2-MIC HAT Tutorial](2mic-hat.md)
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
# Must be run with sudo.
# Requires: curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
set -eo pipefail
kernel_formatted="$(uname -r | cut -f1,2 -d.)"
driver_url_status="$(curl -ILs https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz | tac | grep -o "^HTTP.*" | cut -f 2 -d' ' | head -1)"
if [ ! "$driver_url_status" = 200 ]; then
echo "Could not find driver for kernel $kernel_formatted"
exit 1
fi
apt-get update
apt-get install --no-install-recommends --yes \
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
temp_dir="$(mktemp -d)"
function finish {
rm -rf "${temp_dir}"
}
trap finish EXIT
pushd "${temp_dir}"
# Download source code to temporary directory
# NOTE: There are different branches in the repo for different kernel versions.
echo 'Downloading source code'
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz" | \
tar -xzf -
cd seeed-voicecard-"$kernel_formatted"/
# 1. Build kernel module
echo 'Building kernel module'
ver='0.3'
mod='seeed-voicecard'
src='./'
kernel="$(uname -r)"
marker='0.0.0'
threads="$(getconf _NPROCESSORS_ONLN)"
memory="$(LANG=C free -m|awk '/^Mem:/{print $2}')"
if [ "${memory}" -le 512 ] && [ "${threads}" -gt 2 ]; then
threads=2
fi
mkdir -p "/usr/src/${mod}-${ver}"
cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
dkms add -m "${mod}" -v "${ver}"
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" -j "${threads}" && {
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
}
mkdir -p "/var/lib/dkms/${mod}/${ver}/${marker}"
# 2. Install kernel module and configure
echo 'Updating boot configuration'
config='/boot/config.txt'
cp seeed-*-voicecard.dtbo /boot/overlays
grep -q "^snd-soc-ac108$" /etc/modules || echo "snd-soc-ac108" >> /etc/modules
sed -i -e 's:#dtparam=i2c_arm=on:dtparam=i2c_arm=on:g' "${config}"
echo "dtoverlay=i2s-mmap" >> "${config}"
echo "dtparam=i2s=on" >> "${config}"
mkdir -p /etc/voicecard
cp *.conf *.state /etc/voicecard
cp seeed-voicecard /usr/bin/
cp seeed-voicecard.service /lib/systemd/system/
systemctl enable --now seeed-voicecard.service
echo 'Done. Please reboot the system.'
popd
-282
View File
@@ -1,282 +0,0 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker 2mic HAT, inspired by smart speakers like Alexa and Alice."""
import argparse
import asyncio
import logging
import time
from functools import partial
from math import ceil
from typing import Tuple, List, Optional
import gpiozero
import spidev
from wyoming.asr import Transcript
from wyoming.event import Event
from wyoming.satellite import (
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
NUM_LEDS = 3
LEDS_GPIO = 12
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
parser.add_argument(
"--led-brightness",
type=int,
choices=range(1, 32),
default=31,
help="LED brightness (integer from 1 to 31)",
)
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
led_power = gpiozero.LED(LEDS_GPIO, active_high=False)
led_power.on()
leds = APA102(num_led=NUM_LEDS, global_brightness=args.led_brightness)
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args, leds))
except KeyboardInterrupt:
pass
finally:
leds.cleanup()
led_power.off()
# -----------------------------------------------------------------------------
_BLACK = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
_PURPLE = (128, 0, 128)
_ORANGE = (255, 165, 0)
_CYAN = (0, 255, 255)
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients, with animations inspired by Alexa and Alice."""
def __init__(
self,
cli_args: argparse.Namespace,
leds: "APA102",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
self.leds = leds
self.voice_active = False
self.voice_animation_task: Optional[asyncio.Task] = None
self.streaming_active = False
self.streaming_animation_task: Optional[asyncio.Task] = None
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if StreamingStarted.is_type(event.type):
self.streaming_active = True
if self.streaming_animation_task is None or self.streaming_animation_task.done():
self.streaming_animation_task = asyncio.create_task(self.streaming_animation_loop())
elif Detection.is_type(event.type):
# Как Alexa: directional blue spotlight for wake/listening
await self.rotate_colors([_BLUE, _CYAN, _BLACK], rotations=5, delay=0.2) # Вращение для "просыпания"
self.color(_BLACK)
elif VoiceStarted.is_type(event.type):
self.voice_active = True
if self.voice_animation_task is None or self.voice_animation_task.done():
self.voice_animation_task = asyncio.create_task(self.voice_animation_loop())
elif Transcript.is_type(event.type):
# Как Alexa/Alice: Зеленый успех или эмоция (мигание зеленым для подтверждения)
await self.pulse_color(_GREEN, pulses=2, duration=0.8)
self.color(_BLACK)
elif VoiceStopped.is_type(event.type):
self.voice_active = False
if self.voice_animation_task:
self.voice_animation_task.cancel()
self.voice_animation_task = None
await self.fade_out(_BLUE, steps=10, delay=0.15) # Затухание синего, как конец обработки
elif StreamingStopped.is_type(event.type):
self.streaming_active = False
if self.streaming_animation_task:
self.streaming_animation_task.cancel()
self.streaming_animation_task = None
await self.fade_out(_BLUE, steps=10, delay=0.15)
elif RunSatellite.is_type(event.type):
self.color(_BLACK)
elif SatelliteConnected.is_type(event.type):
# Разовая анимация при подключении, без loop, чтобы в idle не светилось
await self.pulse_color(_GREEN, pulses=2, duration=1.5)
self.color(_BLACK)
elif SatelliteDisconnected.is_type(event.type):
await self.pulse_color(_RED, pulses=3, duration=0.5) # Красный пульс для ошибки, как Alexa purple/red
self.color(_BLACK)
return True
async def voice_animation_loop(self) -> None:
"""Loop анимации для VoiceStarted: как Alexa listening/thinking - alternating blue/cyan."""
try:
while self.voice_active:
await self.gradient([_BLUE, _CYAN, _BLUE], duration=1.5) # Градиент для "thinking"
await self.rotate_colors([_CYAN, _BLUE, _BLACK], rotations=1, delay=0.3) # Вращение для активности
await asyncio.sleep(0.2)
except asyncio.CancelledError:
self.color(_BLACK)
async def streaming_animation_loop(self) -> None:
"""Loop для StreamingStarted: pulsing blue как Alexa processing."""
try:
while self.streaming_active:
await self.pulse_color(_BLUE, pulses=1, duration=0.8)
await asyncio.sleep(0.2)
except asyncio.CancelledError:
self.color(_BLACK)
def color(self, rgb: Tuple[int, int, int]) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2])
self.leds.show()
def set_colors(self, colors: List[Tuple[int, int, int]]) -> None:
for i in range(min(NUM_LEDS, len(colors))):
self.leds.set_pixel(i, colors[i][0], colors[i][1], colors[i][2])
self.leds.show()
async def pulse_color(self, rgb: Tuple[int, int, int], pulses: int = 3, duration: float = 0.5) -> None:
steps = 10
for _ in range(pulses):
for bright in range(steps):
self.set_pixel_bright(rgb, (bright + 1) / steps * 100)
await asyncio.sleep(duration / (2 * steps))
for bright in range(steps, 0, -1):
self.set_pixel_bright(rgb, (bright - 1) / steps * 100)
await asyncio.sleep(duration / (2 * steps))
def set_pixel_bright(self, rgb: Tuple[int, int, int], bright_percent: float) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2], bright_percent)
self.leds.show()
async def rainbow_cycle(self, steps: int = 20, delay: float = 0.05) -> None:
for step in range(steps):
colors = []
for i in range(NUM_LEDS):
r = int((step + i * 85) % 255)
g = int((step + i * 85 + 85) % 255)
b = int((step + i * 85 + 170) % 255)
colors.append((r, g, b))
self.set_colors(colors)
await asyncio.sleep(delay)
self.color(_BLACK)
async def gradient(self, colors: List[Tuple[int, int, int]], duration: float = 1.0) -> None:
steps = 10
for step in range(steps):
blended_colors = []
for i in range(NUM_LEDS):
idx = i % len(colors)
next_idx = (idx + 1) % len(colors)
r = int(colors[idx][0] + (colors[next_idx][0] - colors[idx][0]) * step / steps)
g = int(colors[idx][1] + (colors[next_idx][1] - colors[idx][1]) * step / steps)
b = int(colors[idx][2] + (colors[next_idx][2] - colors[idx][2]) * step / steps)
blended_colors.append((r, g, b))
self.set_colors(blended_colors)
await asyncio.sleep(duration / steps)
async def fade_out(self, start_rgb: Tuple[int, int, int], steps: int = 10, delay: float = 0.1) -> None:
for step in range(steps, 0, -1):
r = int(start_rgb[0] * step / steps)
g = int(start_rgb[1] * step / steps)
b = int(start_rgb[2] * step / steps)
self.color((r, g, b))
await asyncio.sleep(delay)
self.color(_BLACK)
async def rotate_colors(self, colors: List[Tuple[int, int, int]], rotations: int = 3, delay: float = 0.2) -> None:
current_colors = colors[:NUM_LEDS]
for _ in range(rotations):
self.set_colors(current_colors)
await asyncio.sleep(delay)
current_colors = current_colors[-1:] + current_colors[:-1]
# -----------------------------------------------------------------------------
class APA102:
"""
Driver for APA102 LEDS (aka "DotStar").
(c) Martin Erzberger 2016-2017
"""
# Constants
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
def __init__(
self,
num_led,
global_brightness,
order="rgb",
bus=0,
device=1,
max_speed_hz=8000000,
):
self.num_led = num_led # The number of LEDs in the Strip
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
# Limit the brightness to the maximum if it's set higher
if global_brightness > self.MAX_BRIGHTNESS:
self.global_brightness = self.MAX_BRIGHTNESS
else:
self.global_brightness = global_brightness
_LOGGER.debug("LED brightness: %d", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
self.spi = spidev.SpiDev() # Init the SPI device
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
# Up the speed a bit, so that the LEDs are painted faster
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
def clock_start_frame(self):
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0:
return # Pixel is invisible, so ignore
if led_num >= self.num_led:
return # again, invisible
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
self.set_pixel(
led_num,
(rgb_color & 0xFF0000) >> 16,
(rgb_color & 0x00FF00) >> 8,
rgb_color & 0x0000FF,
bright_percent,
)
def rotate(self, positions=1):
cutoff = 4 * (positions % self.num_led)
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close() # Close SPI port
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
-293
View File
@@ -1,293 +0,0 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker 4mic HAT."""
import argparse
import asyncio
import logging
import time
from functools import partial
from math import ceil
from typing import Tuple
import gpiozero
import spidev
from wyoming.asr import Transcript
from wyoming.event import Event
from wyoming.satellite import (
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
NUM_LEDS = 12
LEDS_GPIO = 5
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
#
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
led_power = gpiozero.LED(LEDS_GPIO, active_high=True)
led_power.on()
leds = APA102(num_led=NUM_LEDS)
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args, leds))
except KeyboardInterrupt:
pass
finally:
leds.cleanup()
led_power.off()
# -----------------------------------------------------------------------------
_BLACK = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
leds: "APA102",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
self.leds = leds
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if StreamingStarted.is_type(event.type):
self.color(_YELLOW)
elif Detection.is_type(event.type):
self.color(_BLUE)
await asyncio.sleep(1.0) # show for 1 sec
elif VoiceStarted.is_type(event.type):
self.color(_YELLOW)
elif Transcript.is_type(event.type):
self.color(_GREEN)
await asyncio.sleep(1.0) # show for 1 sec
elif StreamingStopped.is_type(event.type):
self.color(_BLACK)
elif RunSatellite.is_type(event.type):
self.color(_BLACK)
elif SatelliteConnected.is_type(event.type):
# Flash
for _ in range(3):
self.color(_GREEN)
await asyncio.sleep(0.3)
self.color(_BLACK)
await asyncio.sleep(0.3)
elif SatelliteDisconnected.is_type(event.type):
self.color(_RED)
return True
def color(self, rgb: Tuple[int, int, int]) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2])
self.leds.show()
# -----------------------------------------------------------------------------
class APA102:
"""
Driver for APA102 LEDS (aka "DotStar").
(c) Martin Erzberger 2016-2017
"""
# Constants
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
def __init__(
self,
num_led,
global_brightness=MAX_BRIGHTNESS,
order="rgb",
bus=0,
device=1,
max_speed_hz=8000000,
):
self.num_led = num_led # The number of LEDs in the Strip
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
# Limit the brightness to the maximum if it's set higher
if global_brightness > self.MAX_BRIGHTNESS:
self.global_brightness = self.MAX_BRIGHTNESS
else:
self.global_brightness = global_brightness
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
self.spi = spidev.SpiDev() # Init the SPI device
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
# Up the speed a bit, so that the LEDs are painted faster
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
def clock_start_frame(self):
"""Sends a start frame to the LED strip.
This method clocks out a start frame, telling the receiving LED
that it must update its own color now.
"""
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
def clock_end_frame(self):
"""Sends an end frame to the LED strip.
As explained above, dummy data must be sent after the last real colour
information so that all of the data can reach its destination down the line.
The delay is not as bad as with the human example above.
It is only 1/2 bit per LED. This is because the SPI clock line
needs to be inverted.
Say a bit is ready on the SPI data line. The sender communicates
this by toggling the clock line. The bit is read by the LED
and immediately forwarded to the output data line. When the clock goes
down again on the input side, the LED will toggle the clock up
on the output to tell the next LED that the bit is ready.
After one LED the clock is inverted, and after two LEDs it is in sync
again, but one cycle behind. Therefore, for every two LEDs, one bit
of delay gets accumulated. For 300 LEDs, 150 additional bits must be fed to
the input of LED one so that the data can reach the last LED.
Ultimately, we need to send additional numLEDs/2 arbitrary data bits,
in order to trigger numLEDs/2 additional clock changes. This driver
sends zeroes, which has the benefit of getting LED one partially or
fully ready for the next update to the strip. An optimized version
of the driver could omit the "clockStartFrame" method if enough zeroes have
been sent as part of "clockEndFrame".
"""
self.spi.xfer2([0xFF] * 4)
# Round up num_led/2 bits (or num_led/16 bytes)
# for _ in range((self.num_led + 15) // 16):
# self.spi.xfer2([0x00])
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
"""Sets the color of one pixel in the LED stripe.
The changed pixel is not shown yet on the Stripe, it is only
written to the pixel buffer. Colors are passed individually.
If brightness is not set the global brightness setting is used.
"""
if led_num < 0:
return # Pixel is invisible, so ignore
if led_num >= self.num_led:
return # again, invisible
# Calculate pixel brightness as a percentage of the
# defined global_brightness. Round up to nearest integer
# as we expect some brightness unless set to 0
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
# LED startframe is three "1" bits, followed by 5 brightness bits
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
"""Sets the color of one pixel in the LED stripe.
The changed pixel is not shown yet on the Stripe, it is only
written to the pixel buffer.
Colors are passed combined (3 bytes concatenated)
If brightness is not set the global brightness setting is used.
"""
self.set_pixel(
led_num,
(rgb_color & 0xFF0000) >> 16,
(rgb_color & 0x00FF00) >> 8,
rgb_color & 0x0000FF,
bright_percent,
)
def rotate(self, positions=1):
"""Rotate the LEDs by the specified number of positions.
Treating the internal LED array as a circular buffer, rotate it by
the specified number of positions. The number could be negative,
which means rotating in the opposite direction.
"""
cutoff = 4 * (positions % self.num_led)
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
def show(self):
"""Sends the content of the pixel buffer to the strip.
Todo: More than 1024 LEDs requires more than one xfer operation.
"""
self.clock_start_frame()
# xfer2 kills the list, unfortunately. So it must be copied first
# SPI takes up to 4096 Integers. So we are fine for up to 1024 LEDs.
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
"""Release the SPI device; Call this method at the end"""
self.spi.close() # Close SPI port
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
name="$(cat)"
echo "Wake word detected: ${name}"
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'Satellite has started.'
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'Audio streaming to server has started'
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'Audio streaming to server has stopped'
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'User has started speaking'
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'User has stopped speaking'
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
text="$(cat)"
echo "Text to speech text: ${text}"
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
text="$(cat)"
echo "Speech to text transcript: ${text}"
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'Text to speech response has started'
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env sh
echo 'Text to speech response has stopped'
-720
View File
@@ -1,720 +0,0 @@
#!/usr/bin/env python3
"""
ReSpeaker XVF3800 USB 4-MIC Array – LED Controller with Custom Animations.
Implements custom LED animations mirroring the ESPHome home-assistant-voice.yaml effects.
Uses direct USB control transfers to drive the 12 APA102-style LEDs.
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
Muted indicator: LEDs at the 4 mic positions light red.
The XVF3800 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
Ввод цифр 0-10 выбирает анимацию, q - выход.
"""
from __future__ import annotations
import argparse
import logging
import math
import random
import struct
import signal
import sys
import threading
import time
try:
import usb.core
import usb.util
except ImportError:
print("❌ pyusb not installed. Install with: pip install pyusb")
sys.exit(1)
# ===========================================================================
# Configuration
# ===========================================================================
USB_VENDOR_ID = 0x2886
USB_PRODUCT_IDS = [0x0018, 0x001A]
LED_COUNT = 12
LED_BRIGHTNESS = 31 # 0–31 (APA102 5-bit global brightness register)
# GPO_SERVICER_RESID LED commands
GPO_RESID = 20
LED_EFFECT = 12
LED_BRIGHTNESS_CMD = 13
LED_SPEED = 15
LED_COLOR = 16
LED_DOA_COLOR = 17
# Effects
EFFECT_OFF = 0
EFFECT_BREATH = 1
EFFECT_RAINBOW = 2
EFFECT_SINGLE = 3
EFFECT_DOA = 4
EFFECT_RING = 5
# Colors (RGB tuples for custom animations)
RGB = tuple[int, int, int]
BLACK: RGB = (0, 0, 0)
RED: RGB = (255, 0, 0)
BLUE: RGB = (0, 0, 255)
CYAN: RGB = (0, 200, 200)
GREEN: RGB = (0, 200, 50)
YELLOW: RGB = (220, 180, 0)
MAGENTA: RGB = (255, 0, 255)
WHITE: RGB = (255, 255, 255)
# Default ring color matching HA Voice PE
DEFAULT_RING_COLOR: RGB = (24, 187, 242)
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
# 180° → LED 6, 270° → LED 9
MIC_LEDS = (0, 3, 6, 9)
_LOGGER = logging.getLogger("xvf3800-led")
# ===========================================================================
# Helper functions
# ===========================================================================
def _scale(color: RGB, factor: float) -> RGB:
"""Scale RGB color by factor (0.0-1.0)."""
f = max(0.0, min(1.0, factor))
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
def _rgb_to_bgr_int(color: RGB) -> int:
"""Convert RGB tuple to BGR integer (0xBBGGRR format)."""
r, g, b = color
return (b << 16) | (g << 8) | r
# ===========================================================================
# USB LED driver
# ===========================================================================
class XVF3800LED:
"""
Controls the 12 LEDs on ReSpeaker XVF3800 via USB control transfers.
Supports both built-in effects and custom frame-by-frame control.
"""
TIMEOUT = 1000
def __init__(self) -> None:
self._dev = None
self._dev_lock = threading.Lock()
self._find_device()
def _find_device(self) -> None:
_LOGGER.info("Searching for XVF3800 USB device (VID=%04x, PIDs=%s)...",
USB_VENDOR_ID, [f"{pid:04x}" for pid in USB_PRODUCT_IDS])
# List all USB devices for debugging
try:
import usb.core
all_devs = usb.core.find(find_all=True)
_LOGGER.info("All USB devices found:")
for dev in all_devs:
_LOGGER.info(" - VID=%04x, PID=%04x, bus=%d, addr=%d",
dev.idVendor, dev.idProduct, dev.bus, dev.address)
except Exception as exc:
_LOGGER.warning("Could not list USB devices: %s", exc)
for pid in USB_PRODUCT_IDS:
_LOGGER.debug("Looking for PID %04x...", pid)
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
if dev is not None:
# НЕ делаем detach_kernel_driver() — это отключает audio интерфейсы!
# Control transfer с CTRL_TYPE_VENDOR работает без claim interface
# Так работает xvf_host.py — там тоже нет detach_kernel_driver()
self._dev = dev
_LOGGER.info(
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
USB_VENDOR_ID, pid, dev.bus, dev.address,
)
return
else:
_LOGGER.debug("Device %04x:%04x not found", USB_VENDOR_ID, pid)
_LOGGER.error(
"❌ XVF3800 not found (USB %04x). Check connection and udev rules.",
USB_VENDOR_ID,
)
def _ctrl_transfer(self, resid: int, cmdid: int, payload: list) -> bool:
with self._dev_lock:
if self._dev is None:
_LOGGER.warning("USB device not available")
return False
try:
_LOGGER.debug("CTRL: resid=%d, cmdid=%d, payload=%s", resid, cmdid, payload)
self._dev.ctrl_transfer(
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0, cmdid, resid, payload, self.TIMEOUT,
)
return True
except usb.core.USBError as exc:
_LOGGER.warning("USB write error: %s", exc)
self._dev = None
return False
def set_effect(self, effect: int) -> bool:
return self._ctrl_transfer(GPO_RESID, LED_EFFECT, [effect])
def set_brightness(self, brightness: int) -> bool:
brightness = max(0, min(255, brightness))
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS_CMD, [brightness])
def set_speed(self, speed: int) -> bool:
speed = max(0, min(255, speed))
return self._ctrl_transfer(GPO_RESID, LED_SPEED, [speed])
def set_color(self, color: int) -> bool:
payload = list(struct.pack(b'<I', color))
return self._ctrl_transfer(GPO_RESID, LED_COLOR, payload)
def set_doa_color(self, base_color: int, doa_color: int) -> bool:
payload = list(struct.pack(b'<II', base_color, doa_color))
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR, payload)
def set_led_frame(self, pixels: list[RGB], brightness: float = 1.0) -> bool:
"""
Set custom LED frame using LED_RING_COLOR command.
Uses the XVF3800's LED_RING_COLOR command (resid=20, cmdid=19)
which expects 12 × uint32 values (one color per LED in 0xBBGGRR format).
"""
# Build 12 uint32 values in 0xBBGGRR format
colors_uint32 = []
for r, g, b in pixels:
br = max(0, min(255, int(r * brightness)))
bg = max(0, min(255, int(g * brightness)))
bb = max(0, min(255, int(b * brightness)))
# XVF3800 uses 0xBBGGRR format
color_int = (bb << 16) | (bg << 8) | br
colors_uint32.append(color_int)
# Pack as 12 × uint32 little-endian
payload = b''
for color_int in colors_uint32:
payload += struct.pack('<I', color_int)
_LOGGER.debug("LED frame: brightness=%.2f, pixels=%s, payload_len=%d",
brightness, pixels, len(payload))
# Send via vendor control transfer
# resid=20 (GPO_SERVICER_RESID), cmdid=19 (LED_RING_COLOR)
with self._dev_lock:
if self._dev is None:
_LOGGER.warning("❌ LEDs [simulated]: device not available")
_LOGGER.warning(" Payload would be: %s", payload.hex())
return False
try:
result = self._dev.ctrl_transfer(
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0, # request
19, # wValue = cmdid
20, # wIndex = resid
payload,
self.TIMEOUT,
)
_LOGGER.debug("✅ USB transfer successful: %d bytes sent", result)
return True
except usb.core.USBError as exc:
_LOGGER.error("❌ USB write error: %s", exc)
_LOGGER.error(" Payload was: %s", payload.hex())
self._dev = None
return False
def off(self) -> None:
self.set_effect(EFFECT_OFF)
def close(self) -> None:
self.off()
with self._dev_lock:
if self._dev is not None:
usb.util.dispose_resources(self._dev)
self._dev = None
# ===========================================================================
# LED ring animator - custom animations mirroring ESPHome effects
# ===========================================================================
class LEDRing:
"""
Manages the 12-LED ring animations for ReSpeaker XVF3800.
All animations mirror the ESPHome home-assistant-voice.yaml effects.
"""
ANIM_OFF = "off"
ANIM_TWINKLE = "twinkle"
ANIM_TWINKLE_BLUE = "twinkle_blue"
ANIM_WAIT_CMD = "waiting"
ANIM_LISTENING = "listening"
ANIM_THINKING = "thinking"
ANIM_REPLYING = "replying"
ANIM_MUTED = "muted"
ANIM_ERROR = "error"
ANIM_TIMER_TICK = "timer_tick"
ANIM_TIMER_RING = "timer_ring"
def __init__(self) -> None:
self._leds = XVF3800LED()
self._animation = self.ANIM_OFF
self._anim_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name="led-ring"
)
self._index: int = 0
self._brightness_step: int = 0
self._brightness_dec: bool = True
self._twinkle_state: list[float] = [0.0] * LED_COUNT
# Timer state for timer_tick animation
self._timer_total: int = 60
self._timer_left: int = 60
self._timer_tick_count: int = 0
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join(timeout=2)
self._leds.off()
self._leds.close()
def set_animation(self, name: str) -> None:
with self._anim_lock:
if self._animation != name:
_LOGGER.info("LED animation → %s", name)
self._animation = name
self._index = 0
self._brightness_step = 0
self._brightness_dec = True
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
# Reset timer state when entering timer_tick
if name == self.ANIM_TIMER_TICK:
self._timer_total = 60
self._timer_left = 60
self._timer_tick_count = 0
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _color(self) -> RGB:
"""Default ring colour matching HA Voice PE."""
return DEFAULT_RING_COLOR
def _pulse_step(self, steps: int = 10) -> float:
"""Generate pulsing factor 0.0-1.0."""
factor = (steps - self._brightness_step) / steps
if self._brightness_dec:
self._brightness_step += 1
else:
self._brightness_step -= 1
if self._brightness_step <= 0 or self._brightness_step >= steps:
self._brightness_dec = not self._brightness_dec
return factor
def _apply_mic_indicators(self, pixels: list[RGB]) -> None:
"""
Mark all 4 mic positions red on the rendered frame.
ReSpeaker XVF3800 has 4 mics at DOA 0°, 90°, 180°, 270°.
On the 12-LED ring (30° per step):
DOA 0° → LED 0 (right)
DOA 90° → LED 3 (top)
DOA 180° → LED 6 (left)
DOA 270° → LED 9 (bottom)
Neighbours are blanked so each indicator reads as a distinct dot.
"""
pixels[11] = BLACK; pixels[0] = RED; pixels[1] = BLACK
pixels[2] = BLACK; pixels[3] = RED; pixels[4] = BLACK
pixels[5] = BLACK; pixels[6] = RED; pixels[7] = BLACK
pixels[8] = BLACK; pixels[9] = RED; pixels[10] = BLACK
def _apply_mic_indicators_pulsed(self, pixels: list[RGB], factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
pixels[0] = _scale(RED, factor)
pixels[3] = _scale(RED, factor)
pixels[6] = _scale(RED, factor)
pixels[9] = _scale(RED, factor)
# ------------------------------------------------------------------
# Animations — each returns sleep time in seconds
# ------------------------------------------------------------------
def _anim_off(self) -> float:
pixels = [BLACK] * LED_COUNT
self._leds.set_led_frame(pixels, brightness=1.0)
return 0.1
def _anim_twinkle(self, color: RGB) -> float:
FADE = 0.85
SPARK_PROB = 0.15
pixels = [BLACK] * LED_COUNT
for i in range(LED_COUNT):
if random.random() < SPARK_PROB:
self._twinkle_state[i] = 1.0
else:
self._twinkle_state[i] *= FADE
pixels[i] = _scale(color, self._twinkle_state[i])
self._leds.set_led_frame(pixels)
return 0.05
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
"""
Shared spin for Waiting / Listening / Replying.
Mirrors ESPHome "Waiting for Command", "Listening For Command",
and "Replying" effects.
"""
pixels = [BLACK] * LED_COUNT
if reverse:
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
(6, 1.0), (7, 192/255), (8, 128/255)]
else:
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
(6, 1.0), (5, 192/255), (4, 128/255)]
for offset, brightness in offsets:
idx = (self._index + offset) % LED_COUNT
pixels[idx] = _scale(color, brightness)
if not reverse:
self._index = (self._index + 1) % LED_COUNT
self._leds.set_led_frame(pixels)
return interval
def _anim_thinking(self, color: RGB) -> float:
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
factor = self._pulse_step(10)
pixels = [BLACK] * LED_COUNT
for i in range(LED_COUNT):
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
pixels[i] = _scale(color, factor)
self._leds.set_led_frame(pixels)
return 0.01
def _anim_muted(self, color: RGB) -> float:
"""
Solid ring with red at all 4 mic positions (cardinal points).
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
Neighbours are blanked so each indicator stands out clearly.
Mirrors ESPHome "Muted or Silent" effect.
"""
pixels = [color] * LED_COUNT
self._apply_mic_indicators(pixels)
self._leds.set_led_frame(pixels)
return 0.016
def _anim_error(self) -> float:
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
factor = self._pulse_step(10)
pixels = [_scale(RED, factor)] * LED_COUNT
self._leds.set_led_frame(pixels)
return 0.01
def _anim_timer_ring(self, color: RGB) -> float:
"""
Full ring pulsing; red at all 4 mic positions if muted.
Mirrors ESPHome "Timer Ring" effect.
"""
factor = self._pulse_step(10)
pixels = [_scale(color, factor)] * LED_COUNT
self._apply_mic_indicators_pulsed(pixels, factor)
self._leds.set_led_frame(pixels)
return 0.01
def _anim_timer_tick(self, color: RGB, seconds_left: int, total_seconds: int) -> float:
"""
Arc proportional to remaining time with sweep marker dip.
Mirrors ESPHome "Timer Tick" effect exactly.
Visual: A bar graph showing remaining time as an arc of LEDs.
- Full timer = all LEDs lit
- Half timer = 6 LEDs lit
- Each LED represents ~5 seconds (for 60s timer)
- A small brightness dip moves around the arc as a "sweep marker"
"""
total = max(total_seconds, 1)
timer_ratio = LED_COUNT * seconds_left / total
last_led_on = max(0, math.ceil(timer_ratio) - 1)
pixels = [BLACK] * LED_COUNT
for i in range(LED_COUNT):
brightness_dip = (
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
)
if i <= timer_ratio:
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
pixels[i] = _scale(color, brightness)
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._leds.set_led_frame(pixels)
return 0.1
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
def _loop(self) -> None:
_LOGGER.info("LED animation loop started")
frame_count = 0
while not self._stop_event.is_set():
with self._anim_lock:
anim = self._animation
color = self._color()
if frame_count % 30 == 0:
_LOGGER.debug("Animation loop: anim=%s, frame=%d", anim, frame_count)
if anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_TWINKLE:
sleep = self._anim_twinkle(RED)
elif anim == self.ANIM_TWINKLE_BLUE:
sleep = self._anim_twinkle(BLUE)
elif anim == self.ANIM_WAIT_CMD:
sleep = self._anim_spin(color, interval=0.1, reverse=False)
elif anim == self.ANIM_LISTENING:
sleep = self._anim_spin(color, interval=0.05, reverse=False)
elif anim == self.ANIM_THINKING:
sleep = self._anim_thinking(color)
elif anim == self.ANIM_REPLYING:
sleep = self._anim_spin(color, interval=0.05, reverse=True)
elif anim == self.ANIM_MUTED:
sleep = self._anim_muted(color)
elif anim == self.ANIM_ERROR:
sleep = self._anim_error()
elif anim == self.ANIM_TIMER_RING:
sleep = self._anim_timer_ring(color)
elif anim == self.ANIM_TIMER_TICK:
# Simulate timer counting down for demo
with self._anim_lock:
self._timer_tick_count += 1
# Update timer every second (10 frames at 0.1s each)
if self._timer_tick_count % 10 == 0:
self._timer_left = max(0, self._timer_left - 1)
if self._timer_left == 0:
self._timer_left = self._timer_total # Reset for demo
sleep = self._anim_timer_tick(color, self._timer_left, self._timer_total)
else:
sleep = self._anim_off()
time.sleep(sleep)
frame_count += 1
# ===========================================================================
# Main
# ===========================================================================
ANIM_MAP = {
0: LEDRing.ANIM_OFF,
1: LEDRing.ANIM_TWINKLE,
2: LEDRing.ANIM_TWINKLE_BLUE,
3: LEDRing.ANIM_WAIT_CMD,
4: LEDRing.ANIM_LISTENING,
5: LEDRing.ANIM_THINKING,
6: LEDRing.ANIM_REPLYING,
7: LEDRing.ANIM_MUTED,
8: LEDRing.ANIM_ERROR,
9: LEDRing.ANIM_TIMER_TICK,
10: LEDRing.ANIM_TIMER_RING,
}
ANIM_NAMES = {
0: "off - выключить",
1: "twinkle - красные искры",
2: "twinkle_blue - синие искры",
3: "waiting - ожидание (медленное вращение)",
4: "listening - слушание (быстрое вращение)",
5: "thinking - мышление (пульсация)",
6: "replying - ответ (обратное вращение)",
7: "muted --muted (красные точки на позициях микрофонов)",
8: "error - ошибка (красная пульсация)",
9: "timer_tick - таймер (дуга времени)",
10: "timer_ring - таймер звонит (пульсация)",
}
def print_menu() -> None:
print("\n" + "=" * 60)
print(" ReSpeaker XVF3800 LED Controller (Custom Animations)")
print(" Анимации как в ESPHome home-assistant-voice.yaml")
print("=" * 60)
for idx, name in ANIM_NAMES.items():
print(f" {idx:2d} - {name}")
print(" q - Quit")
print("=" * 60)
def run_terminal_controller() -> None:
leds = LEDRing()
leds.start()
def _shutdown(signum, frame) -> None:
print("\n\nShutting down...")
leds.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
# Wait for device to initialize
time.sleep(0.5)
# Check if device was found
if leds._leds._dev is None:
print("\n⚠️ WARNING: XVF3800 device not found!")
print(" Check USB connection and udev rules.")
print(" Run: lsusb | grep -i 2886")
else:
print(f"\n✅ Device connected: bus={leds._leds._dev.bus}, addr={leds._leds._dev.address}")
print_menu()
_LOGGER.info("Starting with default animation: twinkle (red sparks)")
leds.set_animation(LEDRing.ANIM_TWINKLE)
while True:
try:
user_input = input("\nSelect animation (0-10, q to quit): ").strip().lower()
except EOFError:
break
if user_input == "q":
print("Exiting...")
break
try:
anim_idx = int(user_input)
if anim_idx not in ANIM_MAP:
print(f"❌ Invalid selection: {anim_idx}. Choose 0-10 or q.")
continue
anim_name = ANIM_MAP[anim_idx]
leds.set_animation(anim_name)
print(f"✅ Selected: {anim_idx} - {ANIM_NAMES[anim_idx]}")
_LOGGER.info("Animation changed to: %s", anim_name)
except ValueError:
print(f"❌ Invalid input: '{user_input}'. Choose 0-10 or q.")
leds.stop()
def run_test_pattern() -> None:
"""Run test pattern to verify LED hardware."""
print("\n" + "=" * 60)
print(" XVF3800 LED Hardware Test")
print("=" * 60)
leds = LEDRing()
leds.start()
def _shutdown(signum, frame) -> None:
print("\n\nTest interrupted")
leds.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
time.sleep(0.5)
if leds._leds._dev is None:
print("\n❌ ERROR: XVF3800 device not found!")
print(" Check USB connection:")
print(" - Run: lsusb | grep -i 2886")
print(" - Check udev rules: cat /etc/udev/rules.d/99-respeaker.rules")
print(" - Try: sudo chmod 666 /dev/bus/usb/*/`lsusb | grep 2886 | awk '{print $4}' | cut -d/ -f2`")
leds.stop()
return
print(f"\n✅ Device found: bus={leds._leds._dev.bus}, addr={leds._leds._dev.address}")
print("\nRunning test patterns (5 seconds each):")
# Test 1: All red
print(" 1. All RED...")
leds.set_animation(LEDRing.ANIM_OFF)
time.sleep(0.1)
# Direct frame test
pixels = [RED] * LED_COUNT
leds._leds.set_led_frame(pixels, brightness=1.0)
time.sleep(5)
# Test 2: All green
print(" 2. All GREEN...")
pixels = [GREEN] * LED_COUNT
leds._leds.set_led_frame(pixels, brightness=1.0)
time.sleep(5)
# Test 3: All blue
print(" 3. All BLUE...")
pixels = [BLUE] * LED_COUNT
leds._leds.set_led_frame(pixels, brightness=1.0)
time.sleep(5)
# Test 4: Twinkle animation
print(" 4. Twinkle animation (10s)...")
leds.set_animation(LEDRing.ANIM_TWINKLE)
time.sleep(10)
print("\n✅ Test complete!")
leds.stop()
def main() -> None:
parser = argparse.ArgumentParser(
description="ReSpeaker XVF3800 USB 4-MIC Array LED Controller"
)
parser.add_argument(
"--debug", action="store_true",
help="Enable DEBUG logging",
)
parser.add_argument(
"--test", action="store_true",
help="Run LED hardware test pattern",
)
parser.add_argument(
"--verbose", action="store_true",
help="Enable INFO logging to console",
)
args = parser.parse_args()
log_level = logging.DEBUG if args.debug else (logging.INFO if args.verbose else logging.WARNING)
logging.basicConfig(
level=log_level,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
if args.test:
run_test_pattern()
else:
run_terminal_controller()
if __name__ == "__main__":
main()
-10
View File
@@ -1,10 +0,0 @@
{
"listening": 120,
"wake": 200,
"thinking": 150,
"speaking": 140,
"idle": 80,
"paused": 40,
"error": 255,
"global_multiplier": 0.3
}
-789
View File
@@ -1,789 +0,0 @@
#!/usr/bin/env python3
"""
ReSpeaker Mic Array v2.0 (USB) – Linux Voice Assistant peripheral controller.
Connects to the device via USB HID and drives its 12 APA102 LEDs with
animations that mirror the Home Assistant Voice PE LED ring behaviour.
Hardware
--------
Connection : USB (Vendor 0x2886, Product 0x0018)
LED ring : 12 × APA102 RGB LEDs (controlled via USB HID)
Microphones: 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000, USB audio)
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
Muted indicator: LEDs at the 4 mic positions light red.
The v2.0 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
Install dependencies
--------------------
pip install websockets pyusb pixel-ring
Host udev rule (run once on the Pi/host, then reboot or run udevadm trigger):
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
sudo udevadm trigger
Run
---
python3 respeaker_usb_mic_array.py
python3 respeaker_usb_mic_array.py --host 127.0.0.1 --port 6055 --debug
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import math
import random
import signal
import sys
import threading
import time
from enum import Enum
from typing import Optional, Tuple
# ---------------------------------------------------------------------------
# Hardware dependencies — gracefully stubbed when not on real hardware
# ---------------------------------------------------------------------------
try:
import usb.core # type: ignore[import]
import usb.util # type: ignore[import]
_HAS_USB = True
except ImportError:
_HAS_USB = False
logging.warning("pyusb not found – LED output will be simulated")
try:
import websockets # type: ignore[import]
except ImportError:
sys.exit("websockets not installed. Run: pip install websockets")
# ===========================================================================
# Configuration
# ===========================================================================
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# USB device identifiers
USB_VENDOR_ID = 0x2886
USB_PRODUCT_ID = 0x0018
LED_COUNT = 12
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
RECONNECT_DELAY_S = 3.0
# ===========================================================================
# Logging
# ===========================================================================
_LOGGER = logging.getLogger("respeaker-usb")
# ===========================================================================
# Assistant state
# ===========================================================================
class AssistState(str, Enum):
NOT_READY = "not_ready"
IDLE = "idle"
WAKE_WORD = "wake_word_detected"
LISTENING = "listening"
THINKING = "thinking"
SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
ERROR = "error"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
# ---------------------------------------------------------------------------
# Shared state
# ---------------------------------------------------------------------------
class SharedState:
def __init__(self) -> None:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.volume: float = 1.0
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
def update(self, **kwargs) -> None:
with self._lock:
for key, val in kwargs.items():
setattr(self, key, val)
@property
def snapshot(self) -> dict:
with self._lock:
return {
"assist_state": self.assist_state,
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
}
# ===========================================================================
# USB LED driver
#
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
# bytes in the order [start_frame | brightness, B, G, R].
# ===========================================================================
RGB = Tuple[int, int, int]
BLACK: RGB = (0, 0, 0)
RED: RGB = (255, 0, 0)
BLUE: RGB = (0, 0, 255)
CYAN: RGB = (0, 200, 200)
GREEN: RGB = (0, 200, 50)
YELLOW: RGB = (220, 180, 0)
def _scale(color: RGB, factor: float) -> RGB:
f = max(0.0, min(1.0, factor))
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
class USBLEDRing:
"""
Controls the 12 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
This is the same protocol used by Seeed's pixel_ring library.
"""
# USB control transfer parameters (from XMOS firmware)
_CTRL_TIMEOUT = 100 # ms
_CTRL_REQUEST = 0
_CTRL_VALUE = 0x0001
_CTRL_INDEX = 0x8001
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
def __init__(self) -> None:
self._pixels: list[RGB] = [BLACK] * LED_COUNT
self._dev = None
self._dev_lock = threading.Lock()
if _HAS_USB:
self._find_device()
def _find_device(self) -> None:
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=USB_PRODUCT_ID)
if dev is None:
_LOGGER.error(
"ReSpeaker Mic Array v2.0 not found (USB %04x:%04x). "
"Check connection and udev rules.",
USB_VENDOR_ID, USB_PRODUCT_ID,
)
return
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception: # pylint: disable=broad-except
pass
self._dev = dev
_LOGGER.info(
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
)
def set(self, index: int, color: RGB) -> None:
self._pixels[index % LED_COUNT] = color
def set_all(self, color: RGB) -> None:
self._pixels = [color] * LED_COUNT
def show(self, brightness: float = 1.0) -> None:
"""
Push the current pixel buffer to the device.
Frame layout (matches pixel_ring / APA102 USB protocol):
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
"""
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
for r, g, b in self._pixels:
br = max(0, min(255, int(r * brightness)))
bg = max(0, min(255, int(g * brightness)))
bb = max(0, min(255, int(b * brightness)))
frame += [0xE0 | bright5, bb, bg, br]
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
with self._dev_lock:
if self._dev is None:
pixels = ", ".join(f"rgb{p}" for p in self._pixels)
_LOGGER.debug("LEDs [simulated]: %s", pixels)
return
try:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
self._CTRL_VALUE,
self._CTRL_INDEX,
frame,
self._CTRL_TIMEOUT,
)
except usb.core.USBError as exc:
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
self._dev = None
# Attempt to re-find the device on next show() call
threading.Thread(
target=self._reconnect, daemon=True
).start()
def _reconnect(self) -> None:
time.sleep(2)
_LOGGER.info("Attempting to reconnect to ReSpeaker USB device …")
self._find_device()
def off(self) -> None:
self._pixels = [BLACK] * LED_COUNT
self.show(0)
def close(self) -> None:
self.off()
with self._dev_lock:
if self._dev is not None:
usb.util.dispose_resources(self._dev)
self._dev = None
# ===========================================================================
# LED ring animator
#
# All animations mirror the ESPHome home-assistant-voice.yaml effects.
# Muted indicator uses LEDs 0, 2, 4, 6, 8, 10 — the 6 outer mic positions
# on the v2.0 (0°, 60°, 120°, 180°, 240°, 300° on the ring).
# ===========================================================================
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
# 180° → LED 6, 270° → LED 9
MIC_LEDS = (0, 3, 6, 9)
class LEDRing:
"""
Manages the 12-LED ring animations for the ReSpeaker Mic Array v2.0.
All animations run in a dedicated daemon thread.
"""
ANIM_OFF = "off"
ANIM_TWINKLE = "twinkle"
ANIM_TWINKLE_BLUE = "twinkle_blue"
ANIM_WAIT_CMD = "waiting"
ANIM_LISTENING = "listening"
ANIM_THINKING = "thinking"
ANIM_REPLYING = "replying"
ANIM_MUTED = "muted"
ANIM_ERROR = "error"
ANIM_TIMER_TICK = "timer_tick"
ANIM_TIMER_RING = "timer_ring"
def __init__(self, state: SharedState) -> None:
self._state = state
self._leds = USBLEDRing()
self._animation = self.ANIM_OFF
self._anim_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name="led-ring"
)
self._index: int = 0
self._brightness_step: int = 0
self._brightness_dec: bool = True
self._twinkle_state: list[float] = [0.0] * LED_COUNT
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join(timeout=2)
self._leds.off()
self._leds.close()
def set_animation(self, name: str) -> None:
with self._anim_lock:
if self._animation != name:
_LOGGER.debug("LED animation → %s", name)
self._animation = name
self._index = 0
self._brightness_step = 0
self._brightness_dec = True
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _color(self) -> RGB:
"""Default ring colour matching HA Voice PE."""
return (24, 187, 242)
def _pulse_step(self, steps: int = 10) -> float:
factor = (steps - self._brightness_step) / steps
if self._brightness_dec:
self._brightness_step += 1
else:
self._brightness_step -= 1
if self._brightness_step <= 0 or self._brightness_step >= steps:
self._brightness_dec = not self._brightness_dec
return factor
def _apply_mic_indicators(self) -> None:
"""
Mark all 4 mic positions red on the already-rendered frame.
ReSpeaker Mic Array v2.0 has 4 mics at DOA 0°, 90°, 180°, 270°.
On the 12-LED ring (30° per step):
DOA 0° → LED 0 (right)
DOA 90° → LED 3 (top)
DOA 180° → LED 6 (left)
DOA 270° → LED 9 (bottom)
Neighbours are blanked so each indicator reads as a distinct dot.
"""
self._leds.set(11, BLACK); self._leds.set(0, RED); self._leds.set(1, BLACK)
self._leds.set(2, BLACK); self._leds.set(3, RED); self._leds.set(4, BLACK)
self._leds.set(5, BLACK); self._leds.set(6, RED); self._leds.set(7, BLACK)
self._leds.set(8, BLACK); self._leds.set(9, RED); self._leds.set(10, BLACK)
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
self._leds.set(0, _scale(RED, factor))
self._leds.set(3, _scale(RED, factor))
self._leds.set(6, _scale(RED, factor))
self._leds.set(9, _scale(RED, factor))
# ------------------------------------------------------------------
# Animations — each returns sleep time in seconds
# ------------------------------------------------------------------
def _anim_off(self) -> float:
self._leds.off()
return 0.1
def _anim_twinkle(self, color: RGB) -> float:
FADE = 0.85
SPARK_PROB = 0.15
for i in range(LED_COUNT):
if random.random() < SPARK_PROB:
self._twinkle_state[i] = 1.0
else:
self._twinkle_state[i] *= FADE
self._leds.set(i, _scale(color, self._twinkle_state[i]))
self._leds.show()
return 0.05
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
"""
Shared spin for Waiting / Listening / Replying.
Mirrors ESPHome "Waiting for Command", "Listening For Command",
and "Replying" effects.
"""
if reverse:
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
(6, 1.0), (7, 192/255), (8, 128/255)]
else:
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
(6, 1.0), (5, 192/255), (4, 128/255)]
for i in range(LED_COUNT):
self._leds.set(i, BLACK)
for offset, brightness in offsets:
self._leds.set((self._index + offset) % LED_COUNT, _scale(color, brightness))
if not reverse:
self._index = (self._index + 1) % LED_COUNT
self._leds.show()
return interval
def _anim_thinking(self, color: RGB) -> float:
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
self._leds.set(i, _scale(color, factor))
else:
self._leds.set(i, BLACK)
self._leds.show()
return 0.01
def _anim_muted(self, color: RGB) -> float:
"""
Solid ring with red at all 4 mic positions (cardinal points).
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
Neighbours are blanked so each indicator stands out clearly.
Mirrors ESPHome "Muted or Silent" effect.
"""
for i in range(LED_COUNT):
self._leds.set(i, color)
self._apply_mic_indicators()
self._leds.show()
return 0.016
def _anim_error(self) -> float:
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._leds.set(i, _scale(RED, factor))
self._leds.show()
return 0.01
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
"""
Full ring pulsing; red at all 6 mic positions if muted.
Mirrors ESPHome "Timer Ring" effect.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._leds.set(i, _scale(color, factor))
if muted:
self._apply_mic_indicators_pulsed(factor)
self._leds.show()
return 0.01
def _anim_timer_tick(
self, color: RGB, muted: bool,
seconds_left: int, total_seconds: int,
) -> float:
"""
Arc proportional to remaining time with sweep marker dip.
Mirrors ESPHome "Timer Tick" effect exactly.
"""
total = max(total_seconds, 1)
timer_ratio = LED_COUNT * seconds_left / total
last_led_on = max(0, math.ceil(timer_ratio) - 1)
for i in range(LED_COUNT):
brightness_dip = (
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
)
if i <= timer_ratio:
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
self._leds.set(i, _scale(color, brightness))
else:
self._leds.set(i, BLACK)
if muted:
self._apply_mic_indicators()
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._leds.show()
return 0.1
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
def _loop(self) -> None:
while not self._stop_event.is_set():
with self._anim_lock:
anim = self._animation
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
if anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_TWINKLE:
sleep = self._anim_twinkle(RED)
elif anim == self.ANIM_TWINKLE_BLUE:
sleep = self._anim_twinkle(color)
elif anim == self.ANIM_WAIT_CMD:
sleep = self._anim_spin(color, interval=0.1, reverse=False)
elif anim == self.ANIM_LISTENING:
sleep = self._anim_spin(color, interval=0.05, reverse=False)
elif anim == self.ANIM_THINKING:
sleep = self._anim_thinking(color)
elif anim == self.ANIM_REPLYING:
sleep = self._anim_spin(color, interval=0.05, reverse=True)
elif anim == self.ANIM_MUTED:
sleep = self._anim_muted(color)
elif anim == self.ANIM_ERROR:
sleep = self._anim_error()
elif anim == self.ANIM_TIMER_RING:
sleep = self._anim_timer_ring(color, muted)
elif anim == self.ANIM_TIMER_TICK:
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
else:
sleep = self._anim_off()
time.sleep(sleep)
# ===========================================================================
# State → animation mapping (identical to 4-Mic HAT)
# ===========================================================================
def state_to_animation(
state: AssistState, ha_connected: bool, muted: bool
) -> str:
if not ha_connected:
return LEDRing.ANIM_TWINKLE
if state == AssistState.NOT_READY:
return LEDRing.ANIM_TWINKLE
if state == AssistState.TIMER_RINGING:
return LEDRing.ANIM_TIMER_RING
if state == AssistState.WAKE_WORD:
return LEDRing.ANIM_WAIT_CMD
if state == AssistState.LISTENING:
return LEDRing.ANIM_LISTENING
if state == AssistState.THINKING:
return LEDRing.ANIM_THINKING
if state == AssistState.SPEAKING:
return LEDRing.ANIM_REPLYING
if state == AssistState.ERROR:
return LEDRing.ANIM_ERROR
if state == AssistState.MUTED or muted:
return LEDRing.ANIM_MUTED
if state == AssistState.TIMER_TICKING:
return LEDRing.ANIM_TIMER_TICK
return LEDRing.ANIM_OFF
# ===========================================================================
# WebSocket client
# ===========================================================================
class LVAClient:
def __init__(
self,
host: str,
port: int,
state: SharedState,
leds: LEDRing,
command_queue: asyncio.Queue,
) -> None:
self._uri = f"ws://{host}:{port}"
self._state = state
self._leds = leds
self._queue = command_queue
async def run_forever(self) -> None:
while True:
try:
await self._connect()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning(
"LVA connection lost (%s) – retrying in %.0fs",
exc, RECONNECT_DELAY_S,
)
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
await asyncio.sleep(RECONNECT_DELAY_S)
async def _connect(self) -> None:
_LOGGER.info("Connecting to LVA at %s …", self._uri)
async with websockets.connect(self._uri) as ws:
_LOGGER.info("Connected to LVA peripheral API")
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
[recv_task, send_task],
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
for task in done:
if task.exception():
raise task.exception()
async def _recv_loop(self, ws) -> None:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
await self._handle_event(msg)
async def _send_loop(self, ws) -> None:
while True:
command = await self._queue.get()
try:
await ws.send(json.dumps({"command": command}))
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning("Failed to send command %s: %s", command, exc)
self._queue.put_nowait(command)
raise
async def _handle_event(self, msg: dict) -> None:
event = msg.get("event", "")
data = msg.get("data") or {}
_LOGGER.debug("Event: %s data=%s", event, data)
if event == "snapshot":
self._state.update(
muted=data.get("muted", False),
volume=data.get("volume", 1.0),
ha_connected=data.get("ha_connected", False),
)
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
elif event == "listening":
self._state.update(assist_state=AssistState.LISTENING)
elif event == "thinking":
self._state.update(assist_state=AssistState.THINKING)
elif event == "tts_speaking":
self._state.update(assist_state=AssistState.SPEAKING)
elif event in ("tts_finished", "idle"):
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
self._state.update(assist_state=AssistState.MUTED, muted=True)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
self._state.update(assist_state=AssistState.ERROR)
elif event == "disconnected":
_LOGGER.warning("Home Assistant disconnected")
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
elif event == "timer_ticking":
self._state.update(
assist_state=AssistState.TIMER_TICKING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_updated":
self._state.update(
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_ringing":
self._state.update(
assist_state=AssistState.TIMER_RINGING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "media_player_playing":
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
elif event == "zeroconf":
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
# Recompute LED animation after every event
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
# ===========================================================================
# Main
# ===========================================================================
async def async_main(host: str, port: int) -> None:
state = SharedState()
command_queue: asyncio.Queue = asyncio.Queue()
leds = LEDRing(state)
client = LVAClient(host, port, state, leds, command_queue)
leds.start()
leds.set_animation(LEDRing.ANIM_TWINKLE)
def _shutdown(signum, frame) -> None:
_LOGGER.info("Shutdown requested")
leds.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
_LOGGER.info(
"ReSpeaker Mic Array v2.0 controller started – connecting to ws://%s:%d",
host, port,
)
try:
await client.run_forever()
finally:
leds.stop()
def main() -> None:
parser = argparse.ArgumentParser(
description="ReSpeaker Mic Array v2.0 (USB) controller for Linux Voice Assistant"
)
parser.add_argument(
"--host", default=DEFAULT_LVA_HOST,
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
)
parser.add_argument(
"--port", type=int, default=DEFAULT_LVA_PORT,
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
)
parser.add_argument(
"--debug", action="store_true",
help="Enable DEBUG logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
asyncio.run(async_main(args.host, args.port))
if __name__ == "__main__":
main()
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker Mic Array v2.0 (USB) ."""
import argparse
import asyncio
import logging
import time
from functools import partial
from pixel_ring import pixel_ring
from wyoming.event import Event
from wyoming.satellite import (
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.snd import Played
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
pixel_ring.set_color_palette(0x0080FF, 0x007A37)
pixel_ring.think()
await asyncio.sleep(3)
pixel_ring.off()
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args))
except KeyboardInterrupt:
pass
finally:
pixel_ring.off()
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if Detection.is_type(event.type):
_LOGGER.debug("Detection")
pixel_ring.wakeup()
elif VoiceStarted.is_type(event.type):
_LOGGER.debug("VoiceStarted")
pixel_ring.speak()
elif VoiceStopped.is_type(event.type):
_LOGGER.debug("VoiceStopped")
pixel_ring.spin()
elif StreamingStopped.is_type(event.type):
_LOGGER.debug("StreamingStopped")
pixel_ring.off()
elif SatelliteConnected.is_type(event.type):
_LOGGER.debug("SatelliteConnected")
pixel_ring.think()
await asyncio.sleep(2)
pixel_ring.off()
elif Played.is_type(event.type):
_LOGGER.debug("Played")
pixel_ring.off()
elif SatelliteDisconnected.is_type(event.type):
_LOGGER.debug("SatelliteDisconnected")
pixel_ring.mono(0xFF0000)
return True
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
-650
View File
@@ -1,650 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wyoming Satellite Control</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.header h1 {
font-size: 24px;
background: linear-gradient(90deg, #00d9ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 5px;
}
.status {
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.status::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status.connected { background: rgba(0, 255, 136, 0.2); color: #00ff88; }
.status.connected::before { background: #00ff88; }
.status.disconnected { background: rgba(255, 71, 87, 0.2); color: #ff4757; }
.status.disconnected::before { background: #ff4757; animation: none; }
.status.connecting { background: rgba(255, 193, 7, 0.2); color: #ffc107; }
.status.connecting::before { background: #ffc107; }
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
/* Cards */
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 18px;
margin-bottom: 20px;
color: #00d9ff;
display: flex;
align-items: center;
gap: 10px;
}
/* Controls */
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #aaa;
}
/* Volume Slider */
.volume-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(90deg, #00d9ff 0%, #00ff88 100%);
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.volume-value {
text-align: center;
font-size: 24px;
font-weight: bold;
color: #00d9ff;
margin-top: 10px;
}
/* Buttons */
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: linear-gradient(90deg, #00d9ff, #00ff88);
color: #1a1a2e;
}
.btn-danger {
background: linear-gradient(90deg, #ff4757, #ff6b81);
color: white;
}
.btn-success {
background: linear-gradient(90deg, #00ff88, #00d9ff);
color: #1a1a2e;
}
.btn-warning {
background: linear-gradient(90deg, #ffc107, #ff9800);
color: #1a1a2e;
}
.btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* RMS Meter */
.rms-meter {
margin-bottom: 20px;
}
.rms-bar {
height: 30px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.rms-fill {
height: 100%;
background: linear-gradient(90deg, #00ff88 0%, #ffc107 70%, #ff4757 100%);
transition: width 0.1s ease;
border-radius: 4px;
}
.rms-value {
text-align: center;
font-size: 32px;
font-weight: bold;
margin-top: 10px;
font-family: 'Courier New', monospace;
}
.rms-label {
text-align: center;
color: #aaa;
font-size: 12px;
margin-top: 5px;
}
/* Event Log */
.log-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 20px;
height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.log-entry {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid #00d9ff;
background: rgba(0, 217, 255, 0.05);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.log-entry.audio-level {
border-left-color: #00ff88;
background: rgba(0, 255, 136, 0.05);
}
.log-entry.error {
border-left-color: #ff4757;
background: rgba(255, 71, 87, 0.05);
}
.log-entry.system {
border-left-color: #ffc107;
background: rgba(255, 193, 7, 0.05);
}
.log-time {
color: #666;
margin-right: 10px;
}
.log-type {
color: #00d9ff;
font-weight: bold;
}
.log-data {
color: #aaa;
margin-left: 10px;
}
/* Settings */
.settings-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: none;
}
.settings-panel.show {
display: block;
}
.settings-panel input[type="text"] {
width: 100%;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #eee;
font-size: 14px;
margin-bottom: 10px;
}
.settings-panel input[type="text"]:focus {
outline: none;
border-color: #00d9ff;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
color: #aaa;
cursor: pointer;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.grid {
grid-template-columns: 1fr;
}
.btn-group {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div>
<h1>🛰️ Wyoming Satellite Control</h1>
<div style="color: #aaa; font-size: 14px;">WebSocket Client & Audio Level Monitor</div>
</div>
<div class="status" id="status">
Connecting...
</div>
</div>
<!-- Settings -->
<div class="settings-panel" id="settingsPanel">
<label style="color: #00d9ff; font-weight: 600; display: block; margin-bottom: 8px;">
WebSocket URL:
</label>
<input type="text" id="wsUrl" value="ws://localhost:8675">
<div style="display: flex; gap: 10px; align-items: center; margin-top: 10px;">
<button class="btn btn-primary" onclick="connect()">
🔌 Connect
</button>
<label class="checkbox-label">
<input type="checkbox" id="autoReconnect" checked>
Auto-reconnect
</label>
</div>
</div>
<!-- Control Grid -->
<div class="grid">
<!-- Volume Control -->
<div class="card">
<h2>🔊 Volume Control</h2>
<div class="control-group">
<label>Master Volume</label>
<input type="range" class="volume-slider" id="volumeSlider"
min="0" max="100" value="100" oninput="updateVolume(this.value)">
<div class="volume-value" id="volumeValue">100%</div>
</div>
<div class="btn-group">
<button class="btn btn-danger" onclick="sendCommand('mute', {})">
🔇 Mute
</button>
<button class="btn btn-success" onclick="sendCommand('unmute', {})">
🔊 Unmute
</button>
</div>
</div>
<!-- RMS Audio Level -->
<div class="card">
<h2>📊 RMS Audio Level</h2>
<div class="rms-meter">
<div class="rms-bar">
<div class="rms-fill" id="rmsFill" style="width: 0%;"></div>
</div>
<div class="rms-value" id="rmsValue">0.00</div>
<div class="rms-label">Real-time microphone level</div>
</div>
<div class="btn-group">
<button class="btn btn-warning" onclick="sendCommand('play_sound', {path: '/home/sirmrak/projects/satellite-dev/sounds/awake.wav'})">
🔔 Test Sound
</button>
</div>
</div>
<!-- LED Control -->
<div class="card">
<h2>💡 LED Control</h2>
<div class="control-group">
<label>Brightness</label>
<input type="range" class="volume-slider" id="ledBrightness"
min="0" max="255" value="128" oninput="updateLedBrightness(this.value)">
<div class="volume-value" id="ledBrightnessValue">128</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="sendCommand('set_led_color', {r: 0, g: 188, b: 212})">
🔵 Cyan
</button>
<button class="btn btn-danger" onclick="sendCommand('set_led_color', {r: 255, g: 0, b: 0})">
🔴 Red
</button>
<button class="btn btn-success" onclick="sendCommand('set_led_color', {r: 0, g: 255, b: 0})">
🟢 Green
</button>
</div>
</div>
</div>
<!-- Event Log -->
<div class="card">
<h2>📋 Event Log</h2>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<button class="btn btn-primary" onclick="clearLog()">
🗑️ Clear Log
</button>
<button class="btn btn-primary" onclick="toggleSettings()">
⚙️ Settings
</button>
<label class="checkbox-label" style="margin-left: auto;">
<input type="checkbox" id="autoScroll" checked>
Auto-scroll
</label>
</div>
<div class="log-container" id="log"></div>
</div>
</div>
<script>
// State
let ws = null;
let eventCount = 0;
let currentRms = 0;
let rmsTimeout = null;
// Elements
const log = document.getElementById('log');
const status = document.getElementById('status');
const settingsPanel = document.getElementById('settingsPanel');
const wsUrlInput = document.getElementById('wsUrl');
const autoReconnectCheckbox = document.getElementById('autoReconnect');
const rmsFill = document.getElementById('rmsFill');
const rmsValue = document.getElementById('rmsValue');
// Auto-detect WebSocket URL
window.addEventListener('load', () => {
const detectedUrl = `ws://${window.location.hostname}:8675`;
wsUrlInput.value = detectedUrl;
connect();
});
// Connect to WebSocket
function connect() {
const wsUrl = wsUrlInput.value.trim();
if (!wsUrl) {
addEvent('SYSTEM', { error: 'Please enter a WebSocket URL' });
return;
}
// Close existing connection
if (ws) {
ws.close();
}
setStatus('connecting', 'Connecting...');
addEvent('SYSTEM', { status: `Connecting to ${wsUrl}...` });
ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('connected', `Connected`);
addEvent('SYSTEM', { status: `Connected to ${wsUrl}` });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
addEvent(data.type, data.data);
// Handle audio-level events
if (data.type === 'audio-level' && data.data && data.data.level !== undefined) {
updateRmsMeter(data.data.level);
}
} catch (e) {
addEvent('RAW', { raw: event.data });
}
};
ws.onclose = () => {
setStatus('disconnected', 'Disconnected');
addEvent('SYSTEM', { status: 'Connection closed' });
// Auto-reconnect
if (autoReconnectCheckbox.checked) {
setTimeout(() => {
addEvent('SYSTEM', { status: 'Attempting to reconnect...' });
connect();
}, 3000);
}
};
ws.onerror = (err) => {
setStatus('disconnected', 'Error');
addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() });
};
}
// Set status
function setStatus(state, text) {
status.className = `status ${state}`;
status.textContent = text;
}
// Send command
function sendCommand(command, data) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addEvent('SYSTEM', { error: 'Not connected to WebSocket server' });
return;
}
const message = { command, data };
ws.send(JSON.stringify(message));
addEvent('COMMAND', { command, data });
}
// Update volume
function updateVolume(value) {
document.getElementById('volumeValue').textContent = `${value}%`;
}
// Send volume command
document.getElementById('volumeSlider').addEventListener('change', function() {
const level = parseInt(this.value) / 100;
sendCommand('set_volume', { level });
});
// Update LED brightness
function updateLedBrightness(value) {
document.getElementById('ledBrightnessValue').textContent = value;
}
// Send LED brightness command
document.getElementById('ledBrightness').addEventListener('change', function() {
const brightness = parseInt(this.value);
sendCommand('set_led_brightness', { brightness });
});
// Update RMS meter
function updateRmsMeter(level) {
currentRms = level;
const percent = Math.min(100, Math.max(0, level * 100));
rmsFill.style.width = `${percent}%`;
rmsValue.textContent = level.toFixed(2);
// Reset after 1 second of no audio
clearTimeout(rmsTimeout);
rmsTimeout = setTimeout(() => {
currentRms = 0;
rmsFill.style.width = '0%';
rmsValue.textContent = '0.00';
}, 1000);
}
// Toggle settings
function toggleSettings() {
settingsPanel.classList.toggle('show');
}
// Add event to log
function addEvent(type, data) {
eventCount++;
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `log-entry ${type.toLowerCase().replace('_', '-')}`;
let dataStr = '';
if (data.error) {
dataStr = `<span style="color: #ff4757;">${escapeHtml(data.error)}</span>`;
} else if (data.status) {
dataStr = `<span style="color: #00ff88;">${escapeHtml(data.status)}</span>`;
} else if (data.command) {
dataStr = `<span style="color: #ffc107;">→ ${escapeHtml(data.command)}</span> ${escapeHtml(JSON.stringify(data.data))}`;
} else {
dataStr = escapeHtml(JSON.stringify(data, null, 2));
}
div.innerHTML = `
<span class="log-time">[${time}]</span>
<span class="log-type">#${eventCount} ${escapeHtml(type)}</span>
<span class="log-data">${dataStr}</span>
`;
log.appendChild(div);
// Auto-scroll
if (document.getElementById('autoScroll').checked) {
log.scrollTop = log.scrollHeight;
}
}
// Clear log
function clearLog() {
log.innerHTML = '';
eventCount = 0;
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>
@@ -1 +0,0 @@
websockets==12.0
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env python3
import argparse
import asyncio
import json
import logging
from functools import partial
from typing import Optional
import websockets
from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger()
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--websocket-host", default="localhost")
parser.add_argument("--websocket-port", type=int, default=8675)
#
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Start server
server = AsyncServer.from_uri(args.uri)
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
try:
async with websockets.serve(
partial(websocket_connected, queue),
args.websocket_host,
args.websocket_port,
):
await server.run(partial(WebsocketEventHandler, args, queue))
finally:
queue.put_nowait(None)
# -----------------------------------------------------------------------------
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
try:
while True:
event = await queue.get()
if event is None:
# Stop signal
break
await websocket.send(
json.dumps(
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
)
)
except websockets.ConnectionClosed:
pass
except Exception:
_LOGGER.exception("Error in websocket handler")
# -----------------------------------------------------------------------------
class WebsocketEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
queue: "asyncio.Queue[Optional[Event]]",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.queue = queue
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
self.queue.put_nowait(event)
return True
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env python3
import asyncio
import websockets
async def test():
async with websockets.connect("ws://localhost:8675") as ws:
print("Connected!")
while True:
msg = await ws.recv()
print(f"Received: {msg}")
asyncio.run(test())
Binary file not shown.
-244
View File
@@ -1,244 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Wyoming satellite timer example">
<meta name="author" content="Michael Hansen">
<title>Wyoming Timer Example</title>
<style>
body {
background-color: black;
}
.timer-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.timer {
display: flex;
width: 100px;
height: auto;
flex-wrap:wrap;
justify-content: center;
align-items: center;
background-color: #03a9f4;
padding: 15px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
color: white;
margin-right: 20px
}
.time {
font-size: 20px;
}
.message {
text-align: center;
width: 90px;
font-size: 24px;
font-weight: bold;
}
.finished {
background-color: red;
}
.paused {
background-color: yellow;
color: black;
}
.blink {
animation: blink-animation 1s linear infinite;
-webkit-animation: blink-animation 1s linear infinite;
}
@keyframes blink-animation {
0%, 100% {opacity: 1;}
50% {opacity: 0;}
}
@-webkit-keyframes blink-animation {
0%, 100% {opacity: 1;}
50% {opacity: 0;}
}
</style>
</head>
<body>
<div id="timers" class="timer-container">
</div>
<audio id="audio-finished" hidden controls loop src="timer_finished.wav"></audio>
<script>
let socket = new WebSocket("ws://localhost:8675")
var timers = []
var finished_timers = 0
function q(selector) {return document.querySelector(selector)}
socket.onopen = function(e) {
console.log("Connected")
};
socket.onmessage = function(event) {
wyo_event = JSON.parse(event.data)
if (wyo_event.type == "timer-started") {
let timer_info = wyo_event.data
timer_info.is_active = true
console.log(timer_info)
timers.push(timer_info)
let timers_elem = q("#timers")
let timer_div = document.createElement("div")
timer_div.id = "timer-" + timer_info.id
timer_div.classList.add("timer")
let message_div = document.createElement("div")
message_div.classList.add("message")
if (timer_info.name) {
message_div.innerHTML = timer_info.name
} else {
if (timer_info.start_hours) {
message_div.innerHTML += timer_info.start_hours + " hr "
}
if (timer_info.start_minutes) {
message_div.innerHTML += timer_info.start_minutes + " min "
}
if (timer_info.start_seconds) {
message_div.innerHTML += timer_info.start_seconds + " sec "
}
}
timer_div.appendChild(message_div)
let time_div = document.createElement("div")
time_div.classList.add("time")
let hours = timer_info.start_hours ?? 0
let minutes = timer_info.start_minutes ?? 0
let seconds = timer_info.start_seconds ?? 0
time_div.innerHTML =
hours.toString().padStart(2, 0) + ":" +
minutes.toString().padStart(2, 0) + ":" +
seconds.toString().padStart(2, 0);
timer_div.appendChild(time_div)
timers_elem.appendChild(timer_div)
setTimeout(update_timer, 1000, timer_info)
}
else if (wyo_event.type == "timer-cancelled") {
let timer_info = wyo_event.data
timers = timers.filter(t => t.id != timer_info.id)
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
timer_div.remove()
}
}
else if (wyo_event.type == "timer-updated") {
let timer_info = wyo_event.data
for (i = 0; i < timers.length; i++) {
if (timers[i].id == timer_info.id) {
timers[i].is_active = timer_info.is_active
timers[i].total_seconds = timer_info.total_seconds
break
}
}
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
if (timer_info.is_active) {
timer_div.classList.remove("paused")
} else {
timer_div.classList.add("paused")
}
}
}
else if (wyo_event.type == "timer-finished") {
let timer_info = wyo_event.data
for (i = 0; i < timers.length; i++) {
if (timers[i].id == timer_info.id) {
timers[i].total_seconds = 0
finished_timers += 1
if (finished_timers == 1) {
q("#audio-finished").play()
}
break
}
}
timers = timers.filter(t => t.id != timer_info.id)
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
timer_div.classList.add("finished")
timer_div.classList.add("blink")
timer_div.onclick = function() {
timer_div.remove()
finished_timers = Math.max(0, finished_timers - 1)
if (finished_timers == 0) {
let audio = q("#audio-finished")
audio.pause()
audio.currentTime = 0
}
};
}
let time_div = q("#timer-" + timer_info.id + " .time")
if (time_div) {
time_div.style.opacity = 0
}
}
};
socket.onclose = function(event) {
console.log("Disconnected")
};
socket.onerror = function(error) {
};
function update_timer(timer_info) {
if (timer_info.total_seconds < 0) {
return
}
let time_div = q("#timer-" + timer_info.id + " .time")
if (!time_div) {
return
}
if (!timer_info.is_active) {
// Paused
setTimeout(update_timer, 1000, timer_info)
return
}
if (timer_info.total_seconds > 0) {
timer_info.total_seconds -= 1
}
let total_minutes = Math.floor(timer_info.total_seconds / 60)
let hours = Math.floor(total_minutes / 60)
let minutes = total_minutes % 60
let seconds = timer_info.total_seconds % 60
time_div.innerHTML =
hours.toString().padStart(2, 0) + ":" +
minutes.toString().padStart(2, 0) + ":" +
seconds.toString().padStart(2, 0)
setTimeout(update_timer, 1000, timer_info)
};
</script>
</body>
</html>
-492
View File
@@ -1,492 +0,0 @@
#!/usr/bin/env python3
"""
Wyoming LED Control with MQTT Discovery for Home Assistant
Полная версия: Light + Sensor + 7 Number sliders
"""
import argparse
import asyncio
import logging
import sys
import signal
import json
from functools import partial
from pathlib import Path
import paho.mqtt.client as mqtt
from wyoming.event import Event
from wyoming.satellite import SatelliteConnected
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.snd import Played
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
# Импорт USB контроллера
from wyoming_satellite.xvf_usb import (
XVF3800Controller,
EFFECT_OFF, EFFECT_BREATH, EFFECT_RAINBOW, EFFECT_DOA,
rgb_to_bgr_int, scale_color
)
# ====================== НАСТРОЙКИ ======================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger("XVF3800 LED Service")
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
MQTT_BROKER = "192.168.200.100"
MQTT_PORT = 1883
MQTT_USER = "mqtt"
MQTT_PASS = "mqtt"
# Топики
MQTT_TOPIC_COMMAND = "wyoming/led/command"
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
MQTT_TOPIC_STATE = "wyoming/led/state"
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state"
MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state"
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability"
MQTT_DISCOVERY_PREFIX = "homeassistant"
MQTT_CLIENT_ID = "wyoming-led-service"
MQTT_DISCOVERY_DEVICE = {
"identifiers": ["wyoming-led-repeaker"],
"name": "Wyoming LED ReSpeaker",
"manufacturer": "ReSpeaker",
"model": "XVF3800",
}
# Настройки яркости
BRIGHTNESS_CONFIG = {
"listening": 120, "wake": 200, "thinking": 150,
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
"global_multiplier": 1.0
}
mqtt_client = None
current_state = "idle"
last_mqtt_brightness = None
ha_disabled = False
led_controller: XVF3800Controller | None = None
# ====================== CONFIG ======================
def load_brightness_config():
global BRIGHTNESS_CONFIG
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
saved = json.load(f)
BRIGHTNESS_CONFIG.update(saved)
logger.info(f"📂 Загружены настройки яркости из {CONFIG_FILE}")
except Exception as e:
logger.warning(f"⚠️ Ошибка загрузки config: {e}")
else:
save_brightness_config()
def save_brightness_config():
try:
with open(CONFIG_FILE, "w") as f:
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
logger.info(f"💾 Сохранено в {CONFIG_FILE}: {BRIGHTNESS_CONFIG}")
except Exception as e:
logger.error(f"❌ Ошибка сохранения config: {e}")
# ====================== LED ======================
def init_leds() -> bool:
"""Инициализация USB контроллера."""
global led_controller
try:
led_controller = XVF3800Controller()
if led_controller.is_connected():
logger.info("✅ XVF3800 USB подключён")
return True
else:
logger.warning("⚠️ XVF3800 не найден (USB)")
return False
except Exception as e:
logger.error(f"❌ Ошибка инициализации LED: {e}")
return False
def get_effective_brightness(state: str) -> int:
base = BRIGHTNESS_CONFIG.get(state, 100)
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
return int(base * multiplier)
def set_led_state(state: str, skip_publish=False):
"""Установка LED состояния через USB контроллер."""
global current_state
current_state = state
brightness = get_effective_brightness(state)
if led_controller is None:
logger.warning("⚠️ LED контроллер не инициализирован")
if not skip_publish:
publish_state(state)
publish_satellite_state(state)
return
if state == "listening":
# Бирюзовое дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(0, 188, 212)) # 0x00BCD4
led_controller.set_led_brightness(brightness)
led_controller.set_led_speed(2)
logger.info(f"🔵 Listening: бирюзовое дыхание ({brightness})")
elif state == "wake":
# Красное дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(255, 0, 0)) # 0xFF0000
led_controller.set_led_brightness(brightness)
logger.info(f"🔴 Wake: красный ({brightness})")
elif state == "thinking":
# Радуга
led_controller.set_led_effect(EFFECT_RAINBOW)
led_controller.set_led_brightness(brightness)
led_controller.set_led_speed(4)
logger.info(f"🌈 Thinking: радуга ({brightness})")
elif state == "speaking":
# Зелёное дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(76, 175, 80)) # 0x4CAF50
led_controller.set_led_brightness(brightness)
logger.info(f"🟢 Speaking: зелёный ({brightness})")
elif state == "idle":
# DoA эффект: яркость управляется отдельно через set_led_brightness
base_color = rgb_to_bgr_int(0x30, 0x30, 0x30) # 0x303030 - серый
doa_color = rgb_to_bgr_int(0x00, 0xBC, 0xD4) # 0xD4BC00 - бирюзовый (BGR)
led_controller.set_led_effect(EFFECT_DOA)
led_controller.set_led_doa_color(base_color, doa_color)
led_controller.set_led_brightness(brightness)
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base=0x{base_color:06X}, doa=0x{doa_color:06X})")
elif state == "paused":
# Серое дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(64, 64, 64)) # 0x404040
led_controller.set_led_brightness(brightness)
logger.info(f"⚫ Paused: серый ({brightness})")
elif state == "error":
# Маджента дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(255, 0, 255)) # 0xFF00FF
led_controller.set_led_brightness(brightness)
logger.info(f"🟣 Error: маджента ({brightness})")
elif state == "off":
# Выключить
led_controller.set_led_effect(EFFECT_OFF)
logger.info("⬛ Off: выключено")
if not skip_publish:
publish_state(state)
publish_satellite_state(state)
# ====================== MQTT PUBLISH ======================
def publish_discovery_config():
if not mqtt_client:
return
try:
logger.info("🔄 Публикация Discovery...")
# Light
light_disc = {
"name": "Wyoming LED",
"unique_id": "wyoming_led_light",
"state_topic": MQTT_TOPIC_STATE,
"command_topic": MQTT_TOPIC_COMMAND,
"brightness_command_topic": MQTT_TOPIC_BRIGHTNESS_SET,
"brightness_state_topic": MQTT_TOPIC_BRIGHTNESS_STATE,
"availability_topic": MQTT_TOPIC_AVAILABILITY,
"payload_available": "online",
"payload_not_available": "offline",
"payload_on": "ON",
"payload_off": "OFF",
"brightness_scale": 100,
"optimistic": False,
"qos": 1,
"device": MQTT_DISCOVERY_DEVICE,
}
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
json.dumps(light_disc), retain=True, qos=1)
# Sensor
sensor_disc = {
"name": "Wyoming Satellite State",
"unique_id": "wyoming_satellite_state",
"state_topic": MQTT_TOPIC_SATELLITE_STATE,
"availability_topic": MQTT_TOPIC_AVAILABILITY,
"payload_available": "online",
"payload_not_available": "offline",
"device": MQTT_DISCOVERY_DEVICE,
}
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/sensor/wyoming_satellite_state/config",
json.dumps(sensor_disc), retain=True, qos=1)
# Number entities
publish_number_discovery()
logger.info("✅ Discovery опубликован успешно (Light + Sensor + 7 Number)")
# Состояния будут опубликованы с задержкой в on_mqtt_connect
except Exception as e:
logger.error(f"❌ Ошибка Discovery: {e}", exc_info=True)
def publish_number_discovery():
if not mqtt_client:
return
try:
modes = {
"idle": "Idle", "listening": "Listening", "wake": "Wake",
"thinking": "Thinking", "speaking": "Speaking",
"paused": "Paused", "error": "Error"
}
for mode, name in modes.items():
disc = {
"name": f"{name}",
"unique_id": f"wyoming_led_{mode}_base",
"object_id": f"wyoming_led_{mode}_base",
"state_topic": f"wyoming/led/{mode}/state",
"command_topic": f"wyoming/led/{mode}/set",
"availability_topic": MQTT_TOPIC_AVAILABILITY,
"payload_available": "online",
"payload_not_available": "offline",
"min": 0, "max": 100, "step": 5,
"unit_of_measurement": "%",
"icon": "mdi:led-on",
"device": MQTT_DISCOVERY_DEVICE,
}
mqtt_client.publish(
f"{MQTT_DISCOVERY_PREFIX}/number/wyoming_led_{mode}/config",
json.dumps(disc), retain=True, qos=1
)
logger.info("✅ 7 Number entities опубликованы")
except Exception as e:
logger.error(f"❌ Ошибка Number discovery: {e}")
def publish_state(state: str):
if not mqtt_client: return
ha_state = "OFF" if (ha_disabled or state == "off") else "ON"
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
ha_brightness = int(multiplier * 100)
mqtt_client.publish(MQTT_TOPIC_STATE, ha_state, retain=True, qos=1)
mqtt_client.publish(MQTT_TOPIC_BRIGHTNESS_STATE, str(ha_brightness), retain=True, qos=1)
def publish_satellite_state(state: str):
if mqtt_client:
mqtt_client.publish(MQTT_TOPIC_SATELLITE_STATE, state, retain=True, qos=0)
def publish_base_brightness_states():
if not mqtt_client: return
try:
logger.debug(f"📤 Brightness states: {BRIGHTNESS_CONFIG}")
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
if mode in BRIGHTNESS_CONFIG:
percent = min(100, int(BRIGHTNESS_CONFIG[mode] / 2.55))
mqtt_client.publish(f"wyoming/led/{mode}/state", str(percent), retain=True, qos=1)
logger.debug(f"📤 {mode}/state = {percent}% (value={BRIGHTNESS_CONFIG[mode]})")
logger.info("📊 Brightness states опубликованы")
except Exception as e:
logger.error(f"❌ Ошибка publish_base_brightness_states: {e}", exc_info=True)
# ====================== MQTT CALLBACKS ======================
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
if rc == 0:
logger.info(f"✅ MQTT подключён к {MQTT_BROKER}:{MQTT_PORT}")
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True, qos=1)
client.subscribe(MQTT_TOPIC_COMMAND)
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
# Подписка на топики 7 режимов
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
client.subscribe(f"wyoming/led/{mode}/set")
logger.debug(f"📡 Подписка на wyoming/led/{mode}/set")
publish_discovery_config()
publish_state(current_state)
publish_satellite_state(current_state)
publish_base_brightness_states()
logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}, 7 режимов")
else:
logger.error(f"❌ MQTT ошибка подключения: {rc}")
def on_mqtt_message(client, userdata, msg):
global ha_disabled, last_mqtt_brightness, current_state
try:
payload = msg.payload.decode('utf-8').strip()
topic = msg.topic
logger.info(f"📩 MQTT {topic}: {payload}")
# Глобальная яркость
if topic == MQTT_TOPIC_BRIGHTNESS_SET:
ha_value = max(0, min(100, int(payload)))
new_multiplier = ha_value / 100.0
last_mqtt_brightness = ha_value
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
save_brightness_config()
logger.info(f"🔆 Глобальная яркость: {ha_value}% (×{new_multiplier:.2f})")
if not ha_disabled:
set_led_state(current_state)
publish_base_brightness_states()
# Базовая яркость режима
elif topic.startswith("wyoming/led/") and topic.endswith("/set"):
mode = topic.split("/")[2]
logger.info(f"🎚️ Получена команда для режима: {mode}")
if mode in BRIGHTNESS_CONFIG:
percent = max(0, min(100, int(payload)))
real_value = int(percent * 2.55)
old_value = BRIGHTNESS_CONFIG[mode]
BRIGHTNESS_CONFIG[mode] = real_value
save_brightness_config()
logger.info(f"🎛️ {mode}: {old_value} → {real_value} ({percent}%)")
logger.info(f"📤 Публикация {mode}/state = {percent}")
publish_base_brightness_states()
if current_state == mode and not ha_disabled:
logger.info(f"🔄 Применяем новую яркость к текущему состоянию {mode}")
set_led_state(mode)
else:
logger.warning(f"⚠️ Неизвестный режим: {mode}")
# ON / OFF
elif topic == MQTT_TOPIC_COMMAND:
ha_state = payload.upper()
if ha_state == "OFF":
ha_disabled = True
if led_controller:
led_controller.set_led_effect(EFFECT_OFF)
publish_state("off")
logger.info("⬛ LED OFF")
elif ha_state == "ON":
ha_disabled = False
set_led_state(current_state)
logger.info("✅ LED ON")
except Exception as e:
logger.error(f"❌ Ошибка MQTT: {e}", exc_info=True)
# ====================== MQTT CLIENT ======================
def start_mqtt_client():
global mqtt_client
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS)
mqtt_client.on_connect = on_mqtt_connect
mqtt_client.on_message = on_mqtt_message
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
mqtt_client.loop_start()
# ====================== HANDLER & MAIN ======================
class LedEventHandler(AsyncEventHandler):
async def handle_event(self, event: Event) -> bool:
if ha_disabled:
return True
et = event.type
logger.debug(f"🔍 Событие: {et}")
if Detection.is_type(et):
logger.info("🎯 Detection → wake")
set_led_state("wake")
elif VoiceStarted.is_type(et):
logger.info("🎤 VoiceStarted → listening")
set_led_state("listening")
elif VoiceStopped.is_type(et):
logger.info("💭 VoiceStopped → thinking")
set_led_state("thinking")
elif et == "audio-start":
logger.info("🔊 audio-start → speaking")
set_led_state("speaking")
elif Played.is_type(et):
logger.info("✅ Played → idle")
set_led_state("idle")
elif et == "pause-satellite":
logger.info("⏸️ pause → paused")
set_led_state("paused")
elif SatelliteConnected.is_type(et):
logger.info("🔌 connected → idle")
set_led_state("idle")
return True
async def on_connected(self) -> None:
logger.info("✅ TCP подключён")
async def on_disconnected(self) -> None:
logger.info("❌ TCP отключён")
set_led_state("idle")
def cleanup_leds():
"""Очистка: выключаем LED и закрываем USB."""
logger.info("🧹 Очистка: выключаем LED...")
if led_controller:
try:
led_controller.set_led_effect(EFFECT_OFF)
led_controller.close()
logger.info("✅ LED выключены, USB закрыт")
except Exception as e:
logger.error(f"❌ Ошибка очистки: {e}")
else:
logger.info("ℹ️ LED контроллер не был инициализирован")
async def main(args):
logger.info(f"🚀 Запуск на {args.host}:{args.port}")
load_brightness_config()
init_leds()
start_mqtt_client()
set_led_state("idle")
uri = f"tcp://{args.host}:{args.port}"
server = AsyncServer.from_uri(uri)
logger.info(f"📡 Слушаем {uri}")
await server.run(partial(LedEventHandler))
def signal_handler(sig, frame):
logger.info(f"\n🛑 Сигнал {sig}")
cleanup_leds()
if mqtt_client:
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
mqtt_client.loop_stop()
mqtt_client.disconnect()
logger.info("📡 MQTT остановлен")
sys.exit(0)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=10701)
args = parser.parse_args()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
asyncio.run(main(args))
except KeyboardInterrupt:
pass
finally:
cleanup_leds()
-412
View File
@@ -1,412 +0,0 @@
import sys
import struct
import argparse
import usb.core
import usb.util
import libusb_package
import time
CONTROL_SUCCESS = 0
SERVICER_COMMAND_RETRY = 64
# name, resid, cmdid, length, type, description
PARAMETERS = {
# APPLICATION_SERVICER_RESID commands
"VERSION": (48, 0, 3, "ro", "uint8", "The version number of the firmware. The format is VERSION_MAJOR VERSION_MINOR VERSION_PATCH"),
"BLD_MSG": (48, 1, 50, "ro", "char", "Retrieve the build message built in to the firmware, normally the build configuration name"),
"BLD_HOST": (48, 2, 30, "ro", "char", "Retrieve details of the CI build host used to build the firmware"),
"BLD_REPO_HASH": (48, 3, 40, "ro", "char", "Retrieve the GIT hash of the sw_xvf3800 repo used to build the firmware"),
"BLD_MODIFIED": (48, 4, 6, "ro", "char", "Show whether or not the current firmware repo has been modified from the official release. Requires use of a GIT repo"),
"BOOT_STATUS": (48, 5, 3, "ro", "char", "Shows whether or not the firmware has been booted via SPI or JTAG/FLASH"),
"TEST_CORE_BURN": (48, 6, 1, "rw", "uint8", "Set to enable core burn to exercise worst case timing. This will reboot the chip, reset all parameters to default and significantly increase power consumption."),
"REBOOT": (48, 7, 1, "wo", "uint8", "Set to any value to reboot the chip and reset all parameters to default."),
"USB_BIT_DEPTH": (48, 8, 2, "rw", "uint8", "Only relevant for the UA device variant. For the UA device, set or get the USB bit depth IN, OUT to either 16, 24 or 32. Setting will reboot the chip, resetting all other parameters to default. If issued to the INT device, a set is ignored and the device is not rebooted while a get always returns 0 as both IN and OUT bit depths."),
"SAVE_CONFIGURATION": (48, 9, 1, "wo", "uint8", "Set to any value to save the current configuration to flash."),
"CLEAR_CONFIGURATION": (48, 10, 1, "wo", "uint8", "Set to any value to clear the current configuration and revert to the default configuration."),
# AEC_RESID commands
"SHF_BYPASS": (33, 70, 1, "rw", "uint8", "AEC bypass"),
"AEC_NUM_MICS": (33, 71, 1, "ro", "int32", "Number of microphone inputs into the AEC"),
"AEC_NUM_FARENDS": (33, 72, 1, "ro", "int32", "Number of farend inputs into the AEC"),
"AEC_MIC_ARRAY_TYPE": (33, 73, 1, "ro", "int32", "Microphone array type (1 - linear, 2 - squarecular)"),
"AEC_MIC_ARRAY_GEO": (33, 74, 12, "ro", "float", "Microphone array geometry. Each microphone is represented by 3 XYZ coordinates in m."),
"AEC_AZIMUTH_VALUES": (33, 75, 4, "ro", "radians", "Azimuth values in radians - beam 1, beam 2, free-running beam, 3 - auto-select beam"),
"TEST_AEC_DISABLE_CONTROL": (33, 76, 1, "wo", "uint32", "Disable Control processing in the AEC resource. Only to be used for internal testing! Do not use in normal operation. When disabled, there's no way to bring back control without restarting the device."),
"AEC_CURRENT_IDLE_TIME": (33, 77, 1, "ro", "uint32", "AEC processing current idle time in 10ns ticks"),
"AEC_MIN_IDLE_TIME": (33, 78, 1, "ro", "uint32", "AEC processing minimum idle time in 10ns ticks"),
"AEC_RESET_MIN_IDLE_TIME": (33, 79, 1, "wo", "uint32", "Reset the AEC minimum idle time. The value of the argument passed is ignored."),
"AEC_SPENERGY_VALUES": (33, 80, 4, "ro", "float", "Speech energy level values for each beam. Any value above 0 indicates speech. Higher values indicates louder or closer speech, however noise, echo and reverb can cause the energy level to decrease. 0 - beam 1, 1 - beam 2, 2 - free-running beam, 3 - auto-select beam"),
"AEC_FIXEDBEAMSAZIMUTH_VALUES": (33, 81, 2, "rw", "radians", "Azimuth values in radians for beams in fixed mode - fixed beam 1, fixed beam 2"),
"AEC_FIXEDBEAMSELEVATION_VALUES": (33, 82, 2, "rw", "radians", "Elevation angles in radians for the beams in fixed mode. 0 - fixed beam 1, 1 - fixed beam 2"),
"AEC_FIXEDBEAMSGATING": (33, 83, 1, "rw", "uint8", "Enables/disables gating for beams in fixed mode. When enabled, speech energy is used to determine which beam should be active; inactive beams are silenced."),
"SPECIAL_CMD_AEC_FAR_MIC_INDEX": (33, 90, 2, "wo", "int32", "Set Far and Mic index for AEC filter read. Also used as a trigger command that starts a new filter read"),
"SPECIAL_CMD_AEC_FILTER_COEFF_START_OFFSET": (33, 91, 1, "rw", "int32", "start offset to get or set the filter coefficients from"),
"SPECIAL_CMD_AEC_FILTER_COEFFS": (33, 92, 15, "rw", "float", "get or set AEC filter coefficients"),
"SPECIAL_CMD_AEC_FILTER_LENGTH": (33, 93, 1, "ro", "int32", "get AEC filter length"),
"AEC_FILTER_CMD_ABORT": (33, 94, 1, "wo", "int32", "Reset the special command state machine. Used for safely exiting from an AEC filter read/write command sequence that has gone wrong."),
"AEC_AECPATHCHANGE": (33, 0, 1, "ro", "int32", "AEC Path Change Detection. Valid range: 0,1 (false,true)"),
"AEC_HPFONOFF": (33, 1, 1, "rw", "int32", "High-pass Filter on microphone signals. Valid range: 0,1,2,3,4 (0:Off, 1:on70, 2:on125, 3:on150, 4:on180)"),
"AEC_AECSILENCELEVEL": (33, 2, 2, "rw", "float", "Power threshold for signal detection in adaptive filter.(set,cur), Valid range (set): [0.0 .. 1.0] Valid range (cur): 0.05*set, 1e-6f or set."),
"AEC_AECCONVERGED": (33, 3, 1, "ro", "int32", "Flag indicating whether AEC is converged. Valid range: 0,1 (false,true)"),
"AEC_AECEMPHASISONOFF": (33, 4, 1, "rw", "int32", "Pre-emphasis and de-emphasis filtering for AEC. Valid range: 0,1,2 (off,on,on_eq)on: Emphasis filter for speech signals without modification of far-end ref. on_eq: Emphasis filter for far-end reference signals where the low frequencies are boosted by e.g. an equalizer."),
"AEC_FAR_EXTGAIN": (33, 5, 1, "rw", "float", "External gain in dB applied to the far-end reference signals. Valid range: [-inf .. inf]"),
"AEC_PCD_COUPLINGI": (33, 6, 1, "rw", "float", "Sensitivity parameter for PCD. Valid range: [0.0 .. 1.0]PCD can be disabled by setting a value outside the range"),
"AEC_PCD_MINTHR": (33, 7, 1, "rw", "float", "Minimum threshold value used in PCD. Valid range: [0.0 .. 0.02]"),
"AEC_PCD_MAXTHR": (33, 8, 1, "rw", "float", "Maximum threshold value used in PCD. Valid range: [0.025 .. 0.2]"),
"AEC_RT60": (33, 9, 1, "ro", "float", "Current RT60 estimate. Valid range: [0.250 .. 0.900] (seconds) A negative value indicates that the RT60 estimation is invalid."),
"AEC_ASROUTONOFF": (33, 35, 1, "rw", "int32", "Enables the automatic speech recognition (ASR) output: if set to 0, the AEC residuals are output, one channel per microphone, if set to 1, the ASR processed output is used, where each channel is associated with a beam from the beamformer. Valid range: 0,1 (off, on)"),
"AEC_ASROUTGAIN": (33, 36, 1, "rw", "float", "Fixed gain applied to qsr output, i.e., only applied if ASROUTONOFF==1.. Valid range: [0.0 .. 1000.0] (linear gain factor)"),
"AEC_FIXEDBEAMSONOFF": (33, 37, 1, "rw", "int32", "Enables or disables fixed focused beam mode. Valid range: 0,1 (off, on)"),
"AEC_FIXEDBEAMNOISETHR": (33, 38, 2, "rw", "float", "Threshold value for updating the noise canceller when fixed beam mode is enabled. A higher value indicates that the noise canceller may update when the free running beam is close to the fixed beam. A lower value indicates that the noise canceller may update when the free running beam is further away from the focused beam.[BECLEAR_NUMBER_OF_BEAMS], Valid range: [0.0 .. 1.0]"),
# AUDIO_MGR_RESID commands
"AUDIO_MGR_MIC_GAIN": (35, 0, 1, "rw", "float", "Audio Mgr pre SHF microphone gain"),
"AUDIO_MGR_REF_GAIN": (35, 1, 1, "rw", "float", "Audio Mgr pre SHF reference gain"),
"AUDIO_MGR_CURRENT_IDLE_TIME": (35, 2, 1, "ro", "int32", "Get audio manager current idle time in 10ns ticks"),
"AUDIO_MGR_MIN_IDLE_TIME": (35, 3, 1, "ro", "int32", "Get audio manager min idle time in 10ns ticks"),
"AUDIO_MGR_RESET_MIN_IDLE_TIME": (35, 4, 1, "wo", "int32", "Reset audio manager min idle time. The value of the argument passed is ignored."),
"MAX_CONTROL_TIME": (35, 5, 1, "ro", "int32", "Get audio manager max control time"),
"RESET_MAX_CONTROL_TIME": (35, 6, 1, "wo", "int32", "Reset audio manager max control time. The value of the argument passed is ignored."),
"I2S_CURRENT_IDLE_TIME": (35, 7, 1, "ro", "int32", "Get I2S current idle time in 10ns ticks"),
"I2S_MIN_IDLE_TIME": (35, 8, 1, "ro", "int32", "Get I2S min idle time in 10ns ticks"),
"I2S_RESET_MIN_IDLE_TIME": (35, 9, 1, "wo", "int32", "I2S reset idle time. The value of the argument passed is ignored."),
"I2S_INPUT_PACKED": (35, 10, 1, "rw", "uint8", "Will expect packed input on both I2S or USB channels if this is not 0. Note, could take up to 3 samples to take effect."),
"AUDIO_MGR_SELECTED_AZIMUTHS": (35, 11, 2, "ro", "radians", "This returns the azimuths determined by the beam_selection function in post_shf_dsp.c. The default implementation will provide a processed DoA at index 0 and the DoA of the auto select beam at index 1. The processed DoA uses speech energy to select from the DoA on each fixed beam to provide the direction of a speaker. NAN will be returned if neither of the fixed beams contain speech. Versions 2.0.0 and earlier of XVF3800 had a different default implementation of beam_selection which returned the DoA associated with the AUDIO_MGR_SELECTED_CHANNELS"),
"AUDIO_MGR_SELECTED_CHANNELS": (35, 12, 2, "rw", "uint8", "Default implementation of post processing will use this to select which channels should be output to MUX_USER_CHOSEN_CHANNELS. Note that a customer implementation of the beam selection stage could override this command. How this channel selection aligns with actual output depends on the mux configuration"),
"AUDIO_MGR_OP_PACKED": (35, 13, 2, "rw", "uint8", "<L>, <R>; Sets/gets packing status for L and R output channels"),
"AUDIO_MGR_OP_UPSAMPLE": (35, 14, 2, "rw", "uint8", "<L>, <R>; Sets/gets upsample status for L and R output channels, where appropriate"),
"AUDIO_MGR_OP_L": (35, 15, 2, "rw", "uint8", "<category>, <source>; Sets category and source for L output channel. Equivalent to AUDIO_MGR_OP_L_PK0"),
"AUDIO_MGR_OP_L_PK0": (35, 16, 2, "rw", "uint8", "<category>, <source>; Sets category and source for first (of three) sources on the L channel in packed mode. Equivalent to AUDIO_MGR_OP_L"),
"AUDIO_MGR_OP_L_PK1": (35, 17, 2, "rw", "uint8", "Sets category and source for second (of three) sources on the L channel in packed mode"),
"AUDIO_MGR_OP_L_PK2": (35, 18, 2, "rw", "uint8", "Sets category and source for third (of three) sources on the L channel in packed mode"),
"AUDIO_MGR_OP_R": (35, 19, 2, "rw", "uint8", "<category>, <source>; Sets category and source for R output channel. Equivalent to AUDIO_MGR_OP_R_PK0"),
"AUDIO_MGR_OP_R_PK0": (35, 20, 2, "rw", "uint8", "<category>, <source>; Sets category and source for first (of three) sources on the R channel in packed mode. Equivalent to AUDIO_MGR_OP_R"),
"AUDIO_MGR_OP_R_PK1": (35, 21, 2, "rw", "uint8", "Sets category and source for second (of three) sources on the R channel in packed mode"),
"AUDIO_MGR_OP_R_PK2": (35, 22, 2, "rw", "uint8", "Sets category and source for third (of three) sources on the R channel in packed mode"),
"AUDIO_MGR_OP_ALL": (35, 23, 12, "rw", "uint8", "Sets category and source for all 3 sources on L channel and all 3 sources for R channel. Equivalent to AUDIO_MGR_OP_[L,R]_PK[0,1,2] with successive pairs of arguments"),
"I2S_INACTIVE": (35, 24, 1, "ro", "uint8", "Returns whether the main audio loop is exchanging samples with I2S (0). If not (1), I2S is inactive"),
"AUDIO_MGR_FAR_END_DSP_ENABLE": (35, 25, 1, "rw", "uint8", "Enables/disables XVF3800 far-end DSP (if implemented). Write a 1 to enable, 0 to disable"),
"AUDIO_MGR_SYS_DELAY": (35, 26, 1, "rw", "int32", "Delay, measured in samples, that is applied to the reference signal before passing to SHF algorithm"),
"I2S_DAC_DSP_ENABLE": (35, 27, 1, "rw", "uint8", "Indicates if the DAC performs DSP on the far-end reference signal. If enabled (1), use the I2S signal as input to the audio pipeline, even in the UA configuration"),
# GPO_SERVICER_RESID commands
"GPO_READ_VALUES": (20, 0, 5, "ro", "uint8", "Get current logic level of all GPO pins, in order of Pin X0D11, X0D30, X0D31, X0D33 and X0D39."),
"GPO_WRITE_VALUE": (20, 1, 2, "wo", "uint8", "Set current logic level of selected GPO pin. Supports Pin X0D11, X0D30, X0D31, X0D33 and X0D39."),
"GPO_PORT_PIN_INDEX": (20, 2, 2, "rw", "uint32", "GPO port index and pin index that the following commands would be directed to"),
"GPO_PIN_VAL": (20, 3, 3, "wo", "uint8", "value to write to one pin of a GPO port. Payload specifies port_index, pin_index and value to write to the pin"),
"GPO_PIN_ACTIVE_LEVEL": (20, 4, 1, "rw", "uint32", "Active level of the port/pin specified by the GPO_PORT_PIN_INDEX command. 1 = ACTIVE_HIGH, 0 = ACTIVE_LOW"),
"LED_EFFECT": (20, 12, 1, "rw", "uint8", "Set the LED effect mode, 0 = off, 1 = breath, 2 = rainbow, 3 = single color, 4 = doa, 5 = ring"),
"LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8", "Set the brightness value of the LED for the breath and rainbow mode"),
"LED_GAMMIFY": (20, 14, 1, "rw", "uint8", "Set to enable gamma correction, 0 = disable, 1 = enable"),
"LED_SPEED": (20, 15, 1, "rw", "uint8", "Set the effect speed of breath and rainbow mode"),
"LED_COLOR": (20, 16, 1, "rw", "uint32", "Set the LED color of breath mode and single color mode"),
"LED_DOA_COLOR": (20, 17, 2, "rw", "uint32", "Set the LED color of doa mode, the first value is the base color and the second value is the doa color"),
"DOA_VALUE": (20, 18, 2, "ro", "uint16", "Get the doa value and if speech is detected, payload[0] = doa value, from 0 to 359, payload[1] = 1 if speech is detected, 0 if not"),
"LED_RING_COLOR": (20, 19, 12, "rw", "uint32", "Set the LED color of ring mode, each value is the color of one LED"),
# PP_RESID commands
"PP_CURRENT_IDLE_TIME": (17, 70, 1, "ro", "uint32", "PP processing current idle time in 10ns ticks"),
"PP_MIN_IDLE_TIME": (17, 71, 1, "ro", "uint32", "PP processing minimum idle time in 10ns ticks"),
"PP_RESET_MIN_IDLE_TIME": (17, 72, 1, "wo", "uint32", "Reset the PP minimum idle time. The value of the argument passed is ignored."),
"PP_NL_MODEL_CMD_ABORT": (17, 94, 1, "wo", "int32", "Reset the special command state machine. Used for safely exiting from a NL model read/write sequence that has gone wrong."),
"PP_EQUALIZATION_CMD_ABORT": (17, 100, 1, "wo", "int32", "Reset the special command state machine. Used for safely exiting from an equalization read/write sequence that has gone wrong."),
"PP_AGCONOFF": (17, 10, 1, "rw", "int32", "Automatic Gain Control. Valid range: 0,1 (off,on)"),
"PP_AGCMAXGAIN": (17, 11, 1, "rw", "float", "Maximum AGC gain factor. Valid range: [1.0 .. 1000.0] (linear gain factor)"),
"PP_AGCDESIREDLEVEL": (17, 12, 1, "rw", "float", "Target power level of the output signal. Valid range: [1e-8 .. 1.0] (power level)"),
"PP_AGCGAIN": (17, 13, 1, "rw", "float", "Current AGC gain factor. Valid range: [1.0 .. 1000.0] (linear gain factor)"),
"PP_AGCTIME": (17, 14, 1, "rw", "float", "Ramp-up/down time-constant. Valid range: [0.5 .. 4.0] (seconds)"),
"PP_AGCFASTTIME": (17, 15, 1, "rw", "float", "Ramp down time-constant in case of peaks. Valid range: [0.05 .. 4.0] (seconds)"),
"PP_AGCALPHAFASTGAIN": (17, 16, 1, "rw", "float", "Gain threshold enabling fast alpha mode. Valid range: [0.0 .. 1000.0] (linear gain factor)"),
"PP_AGCALPHASLOW": (17, 17, 1, "rw", "float", "Slow memory parameter for speech power. Valid range [0.0 .. 1.0]"),
"PP_AGCALPHAFAST": (17, 18, 1, "rw", "float", "Fast memory parameter for speech power. Valid range [0.0 .. 1.0]"),
"PP_LIMITONOFF": (17, 19, 1, "rw", "int32", "Limiter on communication output. Valid range: 0,1 (off,on)"),
"PP_LIMITPLIMIT": (17, 20, 1, "rw", "float", "Maximum limiter power. Valid range: [1e-8 .. 1.0] (power level)"),
"PP_MIN_NS": (17, 21, 1, "rw", "float", "Gain-floor for stationary noise suppression. Valid range: [0.0 .. 1.0]"),
"PP_MIN_NN": (17, 22, 1, "rw", "float", "Gain-floor for non-stationary noise suppression. Valid range: [0.0 .. 1.0]"),
"PP_ECHOONOFF": (17, 23, 1, "rw", "int32", "Echo suppression. Valid range: 0,1 (off,on)"),
"PP_GAMMA_E": (17, 24, 1, "rw", "float", "Over-subtraction factor of echo (direct and early components). Valid range: [0.0 .. 2.0]"),
"PP_GAMMA_ETAIL": (17, 25, 1, "rw", "float", "Over-subtraction factor of echo (tail components). Valid range: [0.0 .. 2.0]"),
"PP_GAMMA_ENL": (17, 26, 1, "rw", "float", "Over-subtraction factor of non-linear echo. Valid range: [0.0 .. 5.0]"),
"PP_NLATTENONOFF": (17, 27, 1, "rw", "int32", "Non-Linear echo attenuation. Valid range: 0,1 (off,on)"),
"PP_NLAEC_MODE": (17, 28, 1, "rw", "int32", "Non-Linear AEC training mode. Valid range: 0,1,2 (normal,train,train2)"),
"PP_MGSCALE": (17, 29, 3, "rw", "float", "Minimum gain scale for acoustic echo suppression.(max,min,cur), Valid range (max,min): [(1.0,0.0) .. (1e5,max)] Valid range (cur): min or max."),
"PP_FMIN_SPEINDEX": (17, 30, 1, "rw", "float", "In case of double talk, frequencies below SPEINDEX are more suppressed than frequencies above SPEINDEX. The actual suppression is depending on the setting of DTSENSITIVE. The parameter is not a taste parameter but needs to be tuned for a specific device. Valid range: [0.0 .. 7999.0]"),
"PP_DTSENSITIVE": (17, 31, 1, "rw", "int32", "Tradeoff between echo suppression and doubletalk performance. A lower value prefers high echo suppression (possibly at the cost of less doubletalk performance), a higher value prefers better doubletalk performance (possibly at the cost of good echo suppression). Good doubletalk performance is only possible for hardware without much non-linearities. When the value has 2 digits, for robustness an extra near-end speech detector is used. Valid range: [0 .. 5, 10 .. 15]"),
"PP_ATTNS_MODE": (17, 32, 1, "rw", "int32", "Additional reduction of AGC gain during non-speech. Valid range: 0,1 (off, on)"),
"PP_ATTNS_NOMINAL": (17, 33, 1, "rw", "float", "Amount of additional reduction during non-speech at nominal speech level. Valid range: [0.0 .. 1.0]"),
"PP_ATTNS_SLOPE": (17, 34, 1, "rw", "float", "Determines the extra amount of suppression during non-speech when the AGC level increases at lower speech level. The extra attenuation is given by (agcgain_nominal/agcgain_current)^attns_slope. With a value of 1.0 the amount of noise in the output remains approximately the same, independent of the agc gain. Valid range: [0.0 .. 5.0]"),
}
class ReSpeaker:
TIMEOUT = 100000
def __init__(self, dev):
self.dev = dev
def write(self, name, data_list):
try:
data = PARAMETERS[name]
except KeyError:
return
if data[3] == "ro":
raise ValueError('{} is read-only'.format(name))
if len(data_list) != data[2]:
raise ValueError('{} value count is not {}'.format(name, data[2]))
windex = data[0] # resid
wvalue = data[1] # cmdid
data_cnt = data[2] # cnt
data_type = data[4] # data type
payload = []
if data_type == 'float' or data_type == 'radians':
for i in range(data_cnt):
payload += struct.pack(b'f', float(data_list[i]))
elif data_type == 'char':
# For char arrays, convert string to bytes
payload = bytearray(data_list, 'utf-8') if isinstance(data_list, str) else bytearray(data_list)
elif data_type == 'uint8':
for i in range(data_cnt):
payload += data_list[i].to_bytes(1, byteorder='little')
elif data_type == 'uint32' or data_type == 'int32':
for i in range(data_cnt):
payload += struct.pack(b'I' if data_type == 'uint32' else b'i', data_list[i])
else:
# Default to int32 for other types
for i in range(data_cnt):
payload += struct.pack(b'i', data_list[i])
print("WriteCMD: cmdid: {}, resid: {}, payload: {}".format(wvalue, windex, payload))
self.dev.ctrl_transfer(
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0, wvalue, windex, payload, self.TIMEOUT)
def read(self, name):
try:
data = PARAMETERS[name]
except KeyError:
return
read_attempts = 1
windex = data[0] # resid
wvalue = 0x80 | data[1] # cmdid
data_cnt = data[2] # cnt
data_type = data[4] # data type
if data_type == 'uint8' or data_type == 'char':
length = data_cnt + 1 # 1 byte for status
elif data_type == 'float' or data_type == 'radians' or data_type == 'uint32' or data_type == 'int32':
length = data_cnt * 4 + 1 # 1 byte for status
elif data_type == 'uint16':
length = data_cnt * 2 + 1 # 1 byte for status
response = self.dev.ctrl_transfer(
usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0, wvalue, windex, length, self.TIMEOUT)
while True:
if read_attempts > 100:
raise ValueError('Read attempt exceeds 100 times')
if response[0] == CONTROL_SUCCESS:
break
elif response[0] == SERVICER_COMMAND_RETRY:
read_attempts += 1
response = self.dev.ctrl_transfer(
usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0, wvalue, windex, length, self.TIMEOUT)
else:
raise ValueError('Unknown status code: {}'.format(response[0]))
time.sleep(0.01)
print("ReadCMD: cmdid: {}, resid: {}, payload: {}".format(wvalue, windex, response.tolist()))
if data_type == 'uint8':
byte_data = response.tobytes()
match_str = '<'
for i in range(data_cnt):
match_str += 'B'
result = struct.unpack(match_str, byte_data[1:])
elif data_type == 'char':
# For char arrays, convert bytes to string
byte_data = response.tobytes()
# Remove status byte and null terminators
result = byte_data[1:].rstrip(b'\x00').decode('utf-8', errors='ignore')
elif data_type == 'radians' or data_type == 'float':
byte_data = response.tobytes()
match_str = '<'
for i in range(data_cnt):
match_str += 'f'
result = struct.unpack(match_str, byte_data[1:])
elif data_type == 'uint32':
byte_data = response.tobytes()
match_str = '<'
for i in range(data_cnt):
match_str += 'I'
result = struct.unpack(match_str, byte_data[1:])
elif data_type == 'int32':
byte_data = response.tobytes()
match_str = '<'
for i in range(data_cnt):
match_str += 'i'
result = struct.unpack(match_str, byte_data[1:])
elif data_type == 'uint16':
byte_data = response.tobytes()
match_str = '<'
for i in range(data_cnt):
match_str += 'H'
result = struct.unpack(match_str, byte_data[1:])
return result
def close(self):
"""
close the interface
"""
usb.util.dispose_resources(self.dev)
def find(vid=0x2886, pid=0x001A):
if sys.platform.startswith('win'):
dev = libusb_package.find(idVendor=vid, idProduct=pid)
else:
dev = usb.core.find(idVendor=vid, idProduct=pid)
if not dev:
return
return ReSpeaker(dev)
def parse_value(value_str):
"""Parse a value string that can be decimal, hex (0x prefix), or hex ($ prefix)"""
try:
# Try hex formats first (0x prefix or $ prefix)
if value_str.startswith(('0x', '0X')):
return int(value_str, 16)
elif value_str.startswith('$'):
return int(value_str[1:], 16)
else:
# Try as decimal float first, then decimal int
try:
return float(value_str)
except ValueError:
return int(value_str)
except ValueError:
raise ValueError(f"Cannot parse '{value_str}' as a number (supports decimal and hex formats like 0xFF or $FF)")
def case_insensitive_command(value):
"""Convert command to uppercase and validate it exists"""
if not isinstance(value, str):
raise argparse.ArgumentTypeError(f"Command must be a string, got {type(value)}")
upper_value = value.upper()
# Check if the uppercase version exists in PARAMETERS
if upper_value not in PARAMETERS:
# Provide helpful error message with similar commands
available_commands = list(PARAMETERS.keys())
similar_commands = [cmd for cmd in available_commands if upper_value in cmd.upper()]
if similar_commands:
raise argparse.ArgumentTypeError(
f"Invalid command '{value}'. Did you mean one of: {similar_commands}? "
)
else:
raise argparse.ArgumentTypeError(
f"Invalid command '{value}'. "
)
return upper_value
def list_commands():
"""Display all supported commands with their detailed information."""
print("\n=== Supported Commands ===\n")
print(f"{'Command':<30} {'RESID':<6} {'CMDID':<6} {'Length':<7} {'Type':<12} {'Access':<8} {'Description'}")
print("-" * 120)
for name, info in PARAMETERS.items():
resid, cmdid, length, param_type, access, description = info
print(f"{name:<30} {resid:<6} {cmdid:<6} {length:<7} {param_type:<12} {access:<8} {description}")
def main():
parser = argparse.ArgumentParser(description='ReSpeaker Host Control Script')
parser.add_argument('-l', '--list', action='store_true',
help='list all supported commands with detailed information')
parser.add_argument('COMMAND', nargs='?', type=case_insensitive_command,
help='command to execute (e.g., VERSION, DOA_VALUE, etc.)')
parser.add_argument('--vid', type=lambda x: int(x, 0), default=0x2886,
help='usb vendor ID (default: 0x2886)')
parser.add_argument('--pid', type=lambda x: int(x, 0), default=0x001A,
help='usb product ID (default: 0x001A)')
parser.add_argument('--values', nargs='+', type=parse_value,
help='values for write commands (only for write operations). Supports decimal (123, 1.5) and hex (0x7B, $7B) formats')
args = parser.parse_args()
# Handle list command
if args.list:
list_commands()
sys.exit(0)
dev = find(vid=args.vid, pid=args.pid)
if not dev:
print('No device found')
sys.exit(1)
try:
if args.values:
if PARAMETERS[args.COMMAND][3] == "ro":
print(f"Error: {args.COMMAND} is read-only and cannot be written to")
sys.exit(1)
# Convert values to appropriate types based on parameter type
param_type = PARAMETERS[args.COMMAND][4]
if param_type == "float" or param_type == "radians":
args.values = [float(v) for v in args.values]
else:
args.values = [int(v) for v in args.values]
if PARAMETERS[args.COMMAND][2] != len(args.values):
print(f"Error: {args.COMMAND} value count is {PARAMETERS[args.COMMAND][2]}, but {len(args.values)} values provided")
sys.exit(1)
dev.write(args.COMMAND, args.values)
else:
if PARAMETERS[args.COMMAND][3] == "wo":
print(f"Error: {args.COMMAND} is write-only and cannot be read")
resid, cmdid, length, param_type, access, description = PARAMETERS[args.COMMAND]
print(f"{'Command':<30} {'RESID':<6} {'CMDID':<6} {'Length':<7} {'Type':<12} {'Access':<8} {'Description'}")
print("-" * 120)
print(f"{args.COMMAND:<30} {resid:<6} {cmdid:<6} {length:<7} {param_type:<12} {access:<8} {description}")
sys.exit(1)
result = dev.read(args.COMMAND)
if args.COMMAND == "LED_COLOR" or args.COMMAND == "LED_DOA_COLOR" or args.COMMAND == "LED_RING_COLOR":
formatted_result = []
for val in result:
formatted_result.append(f"0x{val:06X}")
print(f"{args.COMMAND}: [{', '.join(formatted_result)}]")
else:
formatted_result = []
for val in result:
if isinstance(val, float):
formatted_result.append(f"{val:.3f}")
elif isinstance(val, str):
formatted_result.append(f"'{val}'")
else:
formatted_result.append(str(val))
print(f"{args.COMMAND}: [{', '.join(formatted_result)}]")
print(f"Done!")
except Exception as e:
print(f"Error executing command {args.COMMAND}: {e}")
sys.exit(1)
finally:
dev.close()
if __name__ == '__main__':
main()
-115
View File
@@ -1,115 +0,0 @@
#!/bin/bash
#
# Установка systemd сервисов для Wyoming Satellite
# Автоматически определяет пути и подставляет их в service файлы
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_DIR="${SCRIPT_DIR}/services"
SYSTEMD_DIR="/etc/systemd/system"
echo "🔧 Установка systemd сервисов для Wyoming Satellite"
echo "=================================================="
# Определяем текущего пользователя
CURRENT_USER="${SUDO_USER:-$(whoami)}"
HOME_DIR=$(getent passwd "$CURRENT_USER" | cut -d: -f6)
echo "👤 Пользователь: $CURRENT_USER"
echo "🏠 Домашняя директория: $HOME_DIR"
# Ищем виртуальное окружение
VENV_PATH=""
for candidate in \
"${HOME_DIR}/projects/.venv_satellite" \
"${HOME_DIR}/.venv_satellite" \
"${SCRIPT_DIR}/.venv" \
"${SCRIPT_DIR}/.venv_satellite"
do
if [ -f "${candidate}/bin/python3" ]; then
VENV_PATH="${candidate}"
break
fi
done
if [ -z "$VENV_PATH" ]; then
echo "❌ Виртуальное окружение не найдено!"
echo " Создайте его: python3 -m venv /path/to/venv"
exit 1
fi
echo "✅ VENV: $VENV_PATH"
# Устанавливаем глобальный wrapper для Python
WRAPPER_SRC="${SCRIPT_DIR}/scripts/satellite-python"
WRAPPER_DST="/usr/local/bin/satellite-python"
if [ -f "$WRAPPER_SRC" ]; then
# Обновляем путь к venv в wrapper'е
sed -i "s|VENV_PATH=.*|VENV_PATH=\"${VENV_PATH}\"|" "$WRAPPER_SRC" 2>/dev/null || true
sudo cp "$WRAPPER_SRC" "$WRAPPER_DST"
sudo chmod +x "$WRAPPER_DST"
echo "✅ Wrapper установлен: $WRAPPER_DST"
else
echo "⚠️ Wrapper не найден, используем полные пути"
fi
# Создаём временную директорию для обработанных сервисов
TEMP_DIR=$(mktemp -d)
trap "rm -rf ${TEMP_DIR}" EXIT
# Обрабатываем каждый service файл
for service_file in "${SERVICE_DIR}"/*.service; do
[ -f "$service_file" ] || continue
filename=$(basename "$service_file")
echo "📄 Обработка: $filename"
# Заменяем плейсхолдеры на актуальные пути
sed -e "s|__SAT_BASE_DIR__|${SCRIPT_DIR}|g" \
-e "s|__SAT_VENV__|${VENV_PATH}|g" \
-e "s|__CURRENT_USER__|${CURRENT_USER}|g" \
-e "s|__HOME_DIR__|${HOME_DIR}|g" \
"$service_file" > "${TEMP_DIR}/${filename}"
# Копируем в systemd
sudo cp "${TEMP_DIR}/${filename}" "${SYSTEMD_DIR}/"
echo " ✅ Установлен в ${SYSTEMD_DIR}/${filename}"
done
# Перезагружаем systemd
echo ""
echo "🔄 Перезагрузка systemd..."
sudo systemctl daemon-reload
echo ""
echo "✅ Установка завершена!"
echo ""
echo "📋 Доступные сервисы:"
echo " - wyoming-event.service (LED управление)"
echo " - wyoming-satelite-vosk.service (спутник)"
echo " - wyoming-wakeword-vosk.service (wake word)"
echo ""
echo "🚀 Команды управления:"
echo " # LED сервис"
echo " sudo systemctl enable wyoming-event.service"
echo " sudo systemctl start wyoming-event.service"
echo " sudo systemctl status wyoming-event.service"
echo ""
echo " # Wyoming Satellite"
echo " sudo systemctl enable wyoming-satelite-vosk.service"
echo " sudo systemctl start wyoming-satelite-vosk.service"
echo " sudo systemctl status wyoming-satelite-vosk.service"
echo ""
echo " # Wake Word сервис"
echo " sudo systemctl enable wyoming-wakeword-vosk.service"
echo " sudo systemctl start wyoming-wakeword-vosk.service"
echo " sudo systemctl status wyoming-wakeword-vosk.service"
echo ""
echo "📊 Просмотр логов:"
echo " journalctl -u wyoming-event.service -f"
echo " journalctl -u wyoming-satelite-vosk.service -f"
echo " journalctl -u wyoming-wakeword-vosk.service -f"
echo ""
-102
View File
@@ -1,102 +0,0 @@
#!/bin/bash
#
# Установка TEST systemd сервисов для Wyoming Satellite (dev branch)
# Автоматически определяет пути и подставляет их в service файлы
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_DIR="${SCRIPT_DIR}/services/test"
SYSTEMD_DIR="/etc/systemd/system"
echo "🔧 Установка TEST systemd сервисов для Wyoming Satellite (dev)"
echo "=============================================================="
# Определяем текущего пользователя
CURRENT_USER="${SUDO_USER:-$(whoami)}"
HOME_DIR=$(getent passwd "$CURRENT_USER" | cut -d: -f6)
echo "👤 Пользователь: $CURRENT_USER"
echo "🏠 Домашняя директория: $HOME_DIR"
# Ищем виртуальное окружение
# Сначала ищем в проекте satellite-dev (для test сервисов)
VENV_PATH=""
for candidate in \
"${SCRIPT_DIR}/.venv" \
"${SCRIPT_DIR}/.venv_satellite" \
"${HOME_DIR}/projects/.venv_satellite" \
"${HOME_DIR}/.venv_satellite"
do
if [ -f "${candidate}/bin/python3" ]; then
VENV_PATH="${candidate}"
break
fi
done
if [ -z "$VENV_PATH" ]; then
echo "❌ Виртуальное окружение не найдено!"
echo " Создайте его: cd ${SCRIPT_DIR} && ./setup_dev.sh"
exit 1
fi
echo "✅ VENV: $VENV_PATH"
# Создаём временную директорию для обработанных сервисов
TEMP_DIR=$(mktemp -d)
trap "rm -rf ${TEMP_DIR}" EXIT
# Обрабатываем каждый test service файл
for service_file in "${SERVICE_DIR}"/*.service; do
[ -f "$service_file" ] || continue
filename=$(basename "$service_file")
echo "📄 Обработка: $filename"
# Заменяем плейсхолдеры на актуальные пути
sed -e "s|__SAT_BASE_DIR__|${SCRIPT_DIR}|g" \
-e "s|__SAT_VENV__|${VENV_PATH}|g" \
-e "s|__CURRENT_USER__|${CURRENT_USER}|g" \
-e "s|__HOME_DIR__|${HOME_DIR}|g" \
"$service_file" > "${TEMP_DIR}/${filename}"
# Копируем в systemd
sudo cp "${TEMP_DIR}/${filename}" "${SYSTEMD_DIR}/"
echo " ✅ Установлен в ${SYSTEMD_DIR}/${filename}"
done
# Перезагружаем systemd
echo ""
echo "🔄 Перезагрузка systemd..."
sudo systemctl daemon-reload
echo ""
echo "✅ TEST сервисы установлены!"
echo ""
echo "📋 TEST сервис (не влияет на production):"
echo " - wyoming-satelite-vosk-test.service (спутник, порт 10702)"
echo ""
echo "💡 Production сервисы должны быть запущены:"
echo " - wyoming-wakeword-vosk.service (:10400)"
echo " - wyoming-event.service (:10500)"
echo ""
echo "🚀 Команды управления:"
echo " # Запуск test satellite"
echo " sudo systemctl start wyoming-satelite-vosk-test.service"
echo ""
echo " # Проверка статуса"
echo " sudo systemctl status wyoming-satelite-vosk-test.service"
echo ""
echo " # Остановка"
echo " sudo systemctl stop wyoming-satelite-vosk-test.service"
echo ""
echo "📊 Просмотр логов:"
echo " journalctl -u wyoming-satelite-vosk-test.service -f"
echo ""
echo "🌐 WebSocket для тестирования:"
echo " ws://127.0.0.1:8675"
echo ""
echo "💡 Пример подключения:"
echo " python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675"
echo ""
-1
View File
@@ -1 +0,0 @@
"""Command-line installer."""
-174
View File
@@ -1,174 +0,0 @@
"""Command-line installer."""
import logging
from typing import List, Optional
from .const import LOCAL_DIR, PROGRAM_DIR, SatelliteType, Settings
from .drivers import install_drivers
from .microphone import configure_microphone
from .packages import (
can_import,
install_packages,
install_packages_nogui,
packages_installed,
)
from .satellite import configure_satellite
from .services import generate_services, install_services, stop_services
from .speakers import configure_speakers
from .wake_word import configure_wake_word
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
_LOGGER = logging.getLogger()
def main() -> None:
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=LOCAL_DIR / "installer.log", filemode="w", level=logging.DEBUG
)
settings = Settings.load()
if not packages_installed("whiptail"):
install_packages_nogui("whiptail")
choice: Optional[str] = None
while True:
choice = main_menu(choice)
if choice == "satellite":
configure_satellite(settings)
elif choice == "microphone":
configure_microphone(settings)
elif choice == "speakers":
configure_speakers(settings)
elif choice == "wake":
configure_wake_word(settings)
elif choice == "drivers":
install_drivers(settings)
elif choice == "apply":
apply_settings(settings)
else:
break
def main_menu(last_choice: Optional[str]) -> Optional[str]:
items: List[ItemType] = [
("satellite", "Satellite"),
("microphone", "Microphone"),
("speakers", "Speakers"),
("wake", "Wake Word"),
("drivers", "Drivers"),
("apply", "Apply Settings"),
]
return menu(
"Main",
items,
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Exit"],
)
# -----------------------------------------------------------------------------
def pip_install(*args) -> List[str]:
return [
str(PROGRAM_DIR / ".venv" / "bin" / "pip3"),
"install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
] + list(args)
def apply_settings(settings: Settings) -> None:
if settings.mic.device is None:
msgbox("Please configure microphone")
return
if (settings.satellite.type == SatelliteType.WAKE) and (
settings.wake.system is None
):
msgbox("Please set wake word system")
return
password: Optional[str] = None
if not packages_installed("python3-pip", "python3-venv"):
password = passwordbox("sudo password:")
if not password:
return
success = install_packages(
"Installing Python packages...", password, "python3-pip", "python3-venv"
)
if not success:
error("installing pip/venv for Python")
return
# Satellite venv
venv_dir = PROGRAM_DIR / ".venv"
if not venv_dir.exists():
result = run_with_gauge(
"Creating virtual environment...", [str(PROGRAM_DIR / "script" / "setup")]
)
if not result:
error("creating virtual environment")
return
# silero (vad)
if (settings.satellite.type == SatelliteType.VAD) and (
not can_import("pysilero_vad")
):
result = run_with_gauge(
"Installing vad...",
[pip_install("-e", f"{PROGRAM_DIR}[silerovad]")],
)
if not result:
error("installing vad")
return
# webrtc (audio enhancements)
if ((settings.mic.noise_suppression > 0) or (settings.mic.auto_gain > 0)) and (
not can_import("webrtc_noise_gain")
):
result = run_with_gauge(
"Installing audio enhancements...",
[pip_install("-e", f"{PROGRAM_DIR}[webrtc]")],
)
if not result:
error("installing audio enhancements")
return
if (
settings.satellite.event_service_command
and (
("2mic" in settings.satellite.event_service_command)
or ("4mic" in settings.satellite.event_service_command)
)
) and (not can_import("gpiozero", "spidev")):
result = run_with_gauge(
"Installing event requirements...",
[pip_install("-e", f"{PROGRAM_DIR}[respeaker]")],
)
if not result:
error("installing event requirements")
return
generate_services(settings)
if password is None:
password = passwordbox("sudo password:")
if not password:
return
stop_services(password)
install_services(settings, password)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main()
-114
View File
@@ -1,114 +0,0 @@
"""Constants and dataclasses."""
import json
import logging
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import List, Optional
from .dataclasses_json import DataClassJsonMixin
_DIR = Path(__file__).parent
_LOGGER = logging.getLogger()
PROGRAM_DIR = _DIR.parent
LOCAL_DIR = PROGRAM_DIR / "local"
SETTINGS_PATH = LOCAL_DIR / "settings.json"
SERVICES_DIR = LOCAL_DIR / "services"
RECORD_SECONDS = 5
RECORD_RMS_MIN = 30
TITLE = "Wyoming Satellite"
WIDTH = "75"
HEIGHT = "20"
LIST_HEIGHT = "12"
class SatelliteType(str, Enum):
ALWAYS_STREAMING = "always"
VAD = "vad"
WAKE = "wake"
@dataclass
class SatelliteSettings(DataClassJsonMixin):
name: str = "Wyoming Satellite"
type: SatelliteType = SatelliteType.ALWAYS_STREAMING
debug: bool = False
event_service_command: Optional[List[str]] = None
@dataclass
class MicrophoneSettings(DataClassJsonMixin):
device: Optional[str] = None
noise_suppression: int = 0
auto_gain: int = 0
volume_multiplier: float = 1.0
@dataclass
class SpeakerSettings(DataClassJsonMixin):
device: Optional[str] = None
volume_multiplier: float = 1.0
feedback_sounds: List[str] = field(default_factory=list)
@dataclass
class OpenWakeWordSettings(DataClassJsonMixin):
wake_word: str = "ok_nabu"
threshold: float = 0.5
trigger_level: int = 1
@dataclass
class Porcupine1Settings(DataClassJsonMixin):
wake_word: str = "porcupine"
sensitivity: float = 0.5
@dataclass
class SnowboySettings(DataClassJsonMixin):
wake_word: str = "snowboy"
sensitivity: float = 0.5
class WakeWordSystem(str, Enum):
OPENWAKEWORD = "openWakeWord"
PORCUPINE1 = "porcupine1"
SNOWBOY = "snowboy"
@dataclass
class WakeWordSettings(DataClassJsonMixin):
system: Optional[WakeWordSystem] = None
openwakeword: OpenWakeWordSettings = field(default_factory=OpenWakeWordSettings)
porcupine1: Porcupine1Settings = field(default_factory=Porcupine1Settings)
snowboy: SnowboySettings = field(default_factory=SnowboySettings)
@dataclass
class Settings(DataClassJsonMixin):
satellite: SatelliteSettings = field(default_factory=SatelliteSettings)
mic: MicrophoneSettings = field(default_factory=MicrophoneSettings)
snd: SpeakerSettings = field(default_factory=SpeakerSettings)
wake: WakeWordSettings = field(default_factory=WakeWordSettings)
@staticmethod
def load() -> "Settings":
if SETTINGS_PATH.exists():
_LOGGER.debug("Loading settings from %s", SETTINGS_PATH)
with open(SETTINGS_PATH, "r", encoding="utf-8") as settings_file:
settings_dict = json.load(settings_file)
return Settings.from_dict(settings_dict)
return Settings()
def save(self) -> None:
_LOGGER.debug("Saving settings to %s", SETTINGS_PATH)
settings_dict = self.to_dict()
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_PATH, "w", encoding="utf-8") as settings_file:
json.dump(settings_dict, settings_file, ensure_ascii=False, indent=2)
-70
View File
@@ -1,70 +0,0 @@
"""Implement a tiny subset of dataclasses_json for config."""
from collections.abc import Mapping, Sequence
from dataclasses import asdict, fields, is_dataclass
from typing import Any, Dict, Type
class DataClassJsonMixin:
"""Adds from_dict to dataclass."""
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Any:
"""Parse dataclasses recursively."""
kwargs: Dict[str, Any] = {}
cls_fields = {field.name: field for field in fields(cls)}
for key, value in data.items():
if key not in cls_fields:
# Skip unknown fields
continue
field = cls_fields[key]
if is_dataclass(field.type):
assert issubclass(field.type, DataClassJsonMixin), field.type
kwargs[key] = field.type.from_dict(value)
else:
kwargs[key] = _decode(value, field.type)
# Fill in optional fields with None
for field in cls_fields.values():
if (field.name not in kwargs) and _is_optional(field.type):
kwargs[field.name] = None
return cls(**kwargs)
def to_dict(self) -> Dict[str, Any]:
"""Alias for asdict."""
return asdict(self)
def _decode(value: Any, target_type: Type) -> Any:
"""Decode value using (possibly generic) type."""
if is_dataclass(target_type):
assert issubclass(target_type, DataClassJsonMixin), target_type
return target_type.from_dict(value) if value is not None else None
if hasattr(target_type, "__args__"):
# Optional[T]
if type(None) in target_type.__args__:
optional_type = target_type.__args__[0]
return _decode(value, optional_type)
# List[T]
if isinstance(value, Sequence):
list_type = target_type.__args__[0]
return [_decode(item, list_type) for item in value]
# Dict[str, T]
if isinstance(value, Mapping):
value_type = target_type.__args__[1]
return {
map_key: _decode(map_value, value_type)
for map_key, map_value in value.items()
}
return value
def _is_optional(target_type: Type):
"""True if type is Optional"""
return hasattr(target_type, "__args__") and (type(None) in target_type.__args__)
-50
View File
@@ -1,50 +0,0 @@
"""Hardware drivers."""
import sys
from typing import Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import error, menu, msgbox, passwordbox, run_with_gauge, yesno
def install_drivers(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Drivers",
[("respeaker", "Install ReSpeaker Drivers")],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "respeaker":
if yesno(
"ReSpeaker drivers for the Raspberry Pi will now be compiled and installed. "
"This will take a long time and require a reboot. "
"Continue?"
):
password = passwordbox("sudo password:")
success = run_with_gauge(
"Installing drivers...",
[
[
"sudo",
"-S",
str(PROGRAM_DIR / "etc" / "install-respeaker-drivers.sh"),
]
],
sudo_password=password,
)
if not success:
error("installing ReSpeaker drivers")
return
msgbox(
"Driver installation complete. "
"Please reboot your Raspberry Pi and re-run the installer. "
'Once rebooted, select the "seeed" microphone.'
)
sys.exit(0)
else:
break
-214
View File
@@ -1,214 +0,0 @@
"""Microphone settings."""
import array
import logging
import math
import subprocess
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Dict, List, Optional
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger()
def configure_microphone(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = microphone_menu(choice)
if choice == "detect":
# Automatically detect microphone with hightest RMS
best_device: Optional[str] = None
best_rms: Optional[float] = None
devices = get_microphone_devices()
with ThreadPoolExecutor() as executor:
futures: Dict[str, Future] = {}
for device in devices:
futures[device] = executor.submit(_record_proc, device)
gauge("Speak loudly into the microphone.", RECORD_SECONDS)
for device, future in futures.items():
device_rms = future.result()
if device_rms is None:
_LOGGER.warning("Failed to record from microphone %s", device)
continue
_LOGGER.debug(
"Microphone %s got RMS %s (min: %s)",
device,
device_rms,
RECORD_RMS_MIN,
)
if device_rms < RECORD_RMS_MIN:
continue
if (best_rms is None) or (device_rms > best_rms):
best_device = device
best_rms = device_rms
if best_device is not None:
msgbox(f"Successfully detected microphone: {best_device}")
settings.mic.device = best_device
settings.save()
else:
msgbox(
"Audio was not detected from any microphone.\n"
"If a satellite is currently running, you may need to stop it."
)
elif choice == "list":
# arecord -L
microphone_device = radiolist(
"Select ALSA Device:", get_microphone_devices(), settings.mic.device
)
if microphone_device:
settings.mic.device = microphone_device
settings.save()
elif choice == "manual":
microphone_device = inputbox("Enter ALSA Device:", settings.mic.device)
if microphone_device:
settings.mic.device = microphone_device
settings.save()
elif choice == "settings":
configure_audio_settings(settings)
else:
break
def microphone_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Microphone",
[
("detect", "Autodetect"),
("list", "Select From List"),
("manual", "Enter Manually"),
("settings", "Audio Settings"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def get_microphone_devices() -> List[str]:
devices = []
lines = subprocess.check_output(["arecord", "-L"]).decode("utf-8").splitlines()
for line in lines:
line = line.strip()
# default = PulseAudio
if (line == "default") or line.startswith("plughw:"):
devices.append(line)
return devices
def _record_proc(device: str) -> Optional[float]:
try:
proc = subprocess.Popen(
[
"arecord",
"-q",
"-D",
device,
"-r",
"16000",
"-c",
"1",
"-f",
"S16_LE",
"-t",
"raw",
"-d",
str(RECORD_SECONDS),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
audio, stderr = proc.communicate()
if proc.returncode != 0:
_LOGGER.error(
"Error recording from device %s: %s", device, stderr.decode("utf-8")
)
return None
# 16-bit mono
audio_array = array.array("h", audio)
rms = math.sqrt(
(1 / len(audio_array) * sum(x * x for x in audio_array.tolist()))
)
return rms
except Exception:
_LOGGER.exception("Error recording from device: %s", device)
return None
return 0
def configure_audio_settings(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = audio_settings_menu(choice)
if choice == "noise":
noise_suppression = radiolist(
"Noise Suppression Level",
[(0, "Off"), (1, "Low"), (2, "Medium"), (3, "High"), (4, "Maximum")],
settings.mic.noise_suppression,
)
if noise_suppression is not None:
settings.mic.noise_suppression = noise_suppression
settings.save()
elif choice == "gain":
while True:
auto_gain = inputbox("Auto Gain (0-31 dbFS)", settings.mic.auto_gain)
if auto_gain is None:
break
try:
auto_gain_int = int(auto_gain)
except ValueError:
msgbox("Invalid value")
continue
if 0 <= auto_gain_int <= 31:
settings.mic.auto_gain = auto_gain_int
settings.save()
break
msgbox("Must be 0-31")
elif choice == "multiplier":
while True:
volume_multiplier = inputbox(
"Volume Multiplier (1 = default)", settings.mic.volume_multiplier
)
if volume_multiplier is None:
break
try:
volume_multiplier_float = float(volume_multiplier)
except ValueError:
msgbox("Invalid value")
continue
if volume_multiplier_float > 0:
settings.mic.volume_multiplier = volume_multiplier_float
settings.save()
break
msgbox("Must be > 0")
else:
break
def audio_settings_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Microphone > Audio Settings",
[
("noise", "Noise Suppression"),
("gain", "Auto Gain"),
("multiplier", "Volume Multiplier"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
-87
View File
@@ -1,87 +0,0 @@
"""Install system packages."""
import logging
import subprocess
from .const import PROGRAM_DIR
from .whiptail import run_with_gauge
_LOGGER = logging.getLogger()
def packages_installed(*packages) -> bool:
for package in packages:
try:
subprocess.check_call(
["dpkg", "--status", str(package)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return False
return True
def install_packages_nogui(*packages, update: bool = True) -> bool:
assert packages, "No packages"
commands = []
if update:
commands.append(["sudo", "apt-get", "update"])
commands.append(
["sudo", "apt-get", "install", "--yes"] + [str(p) for p in packages]
)
try:
for command in commands:
subprocess.check_call(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception:
_LOGGER.exception("Unexpected error installing packages: %s", packages)
return False
return True
def install_packages(
text: str, sudo_password: str, *packages, update: bool = True
) -> bool:
assert packages, "No packages"
commands = []
if update:
commands.append(["sudo", "-S", "apt-get", "update"])
commands.append(
["sudo", "-S", "apt-get", "install", "--yes"] + [str(p) for p in packages]
)
return run_with_gauge(text, commands, sudo_password=sudo_password)
def can_import(*names) -> bool:
assert names, "No names"
venv_dir = PROGRAM_DIR / ".venv"
if not venv_dir.exists():
return False
try:
import venv
except ImportError:
return False
context = venv.EnvBuilder().ensure_directories(PROGRAM_DIR / ".venv")
try:
subprocess.check_call(
[context.env_exe, "-c", "; ".join(f"import {name}" for name in names)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return False
return True
-120
View File
@@ -1,120 +0,0 @@
"""Satellite settings."""
from typing import Optional
from .const import PROGRAM_DIR, SatelliteType, Settings
from .whiptail import error, inputbox, menu, passwordbox, radiolist, run_with_gauge
def configure_satellite(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = satellite_menu(choice)
if choice == "name":
name = inputbox("Satellite Name:", settings.satellite.name)
if name:
settings.satellite.name = name
settings.save()
elif choice == "type":
satellite_type = radiolist(
"Satellite Type:",
[
(SatelliteType.ALWAYS_STREAMING, "Always streaming"),
(SatelliteType.VAD, "Voice activity detection"),
(SatelliteType.WAKE, "Local wake word detection"),
],
settings.satellite.type,
)
if satellite_type is not None:
settings.satellite.type = SatelliteType(satellite_type)
settings.save()
elif choice == "feedback":
configure_feedback(settings)
elif choice in ("restart", "stop", "start"):
password = passwordbox("sudo password:")
if not password:
continue
command = ["sudo", "-S", "systemctl", choice, "wyoming-satellite.service"]
text = {"restart": "Restarting", "stop": "Stopping", "start": "Starting"}[
choice
]
success = run_with_gauge(
f"{text} satellite...", [command], sudo_password=password
)
if not success:
error(f"{text.lower()} satellite")
elif choice == "debug":
debug = radiolist(
"Debug Mode:",
[
("disabled", "Disabled"),
("enabled", "Enabled"),
],
"enabled" if settings.satellite.debug else "disabled",
)
if debug is not None:
settings.satellite.debug = debug == "enabled"
settings.save()
else:
break
def satellite_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Satellite",
[
("name", "Satellite Name"),
("type", "Satellite Type"),
("feedback", "Feedback"),
("restart", "Restart Services"),
("stop", "Stop Services"),
("start", "Start Services"),
("debug", "Set Debug Mode"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def configure_feedback(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Satellite > Feedback",
[("respeaker", "ReSpeaker")],
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "respeaker":
selected_service: Optional[str] = None
if settings.satellite.event_service_command:
if "2mic" in settings.satellite.event_service_command[0]:
selected_service = "2mic"
elif "4mic" in settings.satellite.event_service_command[0]:
selected_service = "4mic"
event_service = radiolist(
"Event Service:",
[
("none", "None"),
("2mic", "2mic LEDs"),
("4mic", "4mic LEDs"),
],
selected_service,
)
if event_service is not None:
if event_service == "none":
settings.satellite.event_service_command = None
else:
settings.satellite.event_service_command = [
str(PROGRAM_DIR / "script" / f"run_{event_service}"),
"--uri",
"tcp://127.0.0.1:10500",
]
settings.save()
else:
break
-262
View File
@@ -1,262 +0,0 @@
"""Systemd service management."""
import shlex
import subprocess
from pathlib import Path
from typing import List
from .const import (
LOCAL_DIR,
PROGRAM_DIR,
SERVICES_DIR,
SatelliteType,
Settings,
WakeWordSystem,
)
from .whiptail import error, msgbox, run_with_gauge
def stop_services(password: str) -> None:
stop_commands = []
for service in ("satellite", "wakeword", "event"):
service_filename = f"wyoming-{service}.service"
service_path = Path("/etc/systemd/system") / service_filename
if not service_path.exists():
continue
stop_commands.append(["sudo", "-S", "systemctl", "stop", service_filename])
stop_commands.append(["sudo", "-S", "systemctl", "disable", service_filename])
run_with_gauge("Stopping Services...", stop_commands, sudo_password=password)
def generate_services(settings: Settings) -> None:
SERVICES_DIR.mkdir(parents=True, exist_ok=True)
user_id = subprocess.check_output(["id", "-u"], text=True).strip()
user_name = subprocess.check_output(["id", "--name", "-u"], text=True).strip()
satellite_command: List[str] = [
str(PROGRAM_DIR / "script" / "run"),
"--name",
settings.satellite.name,
"--uri",
"tcp://0.0.0.0:10700",
"--mic-command",
f"arecord -D {settings.mic.device} -q -r 16000 -c 1 -f S16_LE -t raw",
]
satellite_requires: List[str] = []
if settings.snd.device is not None:
# Audio output
satellite_command.extend(
[
"--snd-command",
f"aplay -D {settings.snd.device} -q -r 22050 -c 1 -f S16_LE -t raw",
]
)
if settings.snd.volume_multiplier != 1.0:
satellite_command.extend(
["--snd-volume-multiplier", str(settings.snd.volume_multiplier)]
)
for sound_name in settings.snd.feedback_sounds:
# Try local/sounds first
sound_path = LOCAL_DIR / "sounds" / f"{sound_name}.wav"
if not sound_path.exists():
sound_path = PROGRAM_DIR / "sounds" / f"{sound_name}.wav"
satellite_command.extend([f"--{sound_name}-wav", str(sound_path)])
if settings.mic.noise_suppression > 0:
satellite_command.extend(
["--mic-noise-suppression", str(settings.mic.noise_suppression)]
)
if settings.mic.auto_gain > 0:
satellite_command.extend(["--mic-auto-gain", str(settings.mic.auto_gain)])
if settings.mic.volume_multiplier != 1.0:
satellite_command.extend(
["--mic-volume-multiplier", str(settings.mic.volume_multiplier)]
)
if settings.satellite.type == SatelliteType.VAD:
# Voice activity detect
satellite_command.append("--vad")
elif settings.satellite.type == SatelliteType.WAKE:
# Local wake word detection
assert settings.wake.system is not None, "Wake word system not set"
wake_word_service = "wyoming-wakeword"
wake_word_dir = LOCAL_DIR / (
"wyoming-" + WakeWordSystem(settings.wake.system).value.lower()
)
wake_word_command = [
str(wake_word_dir / "script" / "run"),
"--uri",
"tcp://127.0.0.1:10400",
]
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
wake_word = settings.wake.openwakeword.wake_word
wake_word_command.extend(
[
"--threshold",
str(settings.wake.openwakeword.threshold),
"--trigger-level",
str(settings.wake.openwakeword.trigger_level),
"--custom-model-dir",
str(LOCAL_DIR / "custom-wake-words" / "openWakeWord"),
]
)
elif settings.wake.system == WakeWordSystem.PORCUPINE1:
wake_word = settings.wake.porcupine1.wake_word
wake_word_command.extend(
["--sensitivity", str(settings.wake.porcupine1.sensitivity)]
)
elif settings.wake.system == WakeWordSystem.SNOWBOY:
wake_word = settings.wake.snowboy.wake_word
wake_word_command.extend(
[
"--sensitivity",
str(settings.wake.snowboy.sensitivity),
"--custom-model-dir",
str(LOCAL_DIR / "custom-wake-words" / "snowboy"),
]
)
else:
raise ValueError(settings.wake.system)
if settings.satellite.debug:
wake_word_command.append("--debug")
wake_word_command_str = shlex.join(wake_word_command)
with open(
SERVICES_DIR / f"{wake_word_service}.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print(
f"Description={WakeWordSystem(settings.wake.system).value}",
file=service_file,
)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
print(f"ExecStart={wake_word_command_str}", file=service_file)
print(f"WorkingDirectory={wake_word_dir}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
satellite_command.extend(
[
"--wake-uri",
"tcp://127.0.0.1:10400",
"--wake-word-name",
wake_word,
]
)
satellite_requires.append(f"{wake_word_service}.service")
if settings.satellite.event_service_command:
event_service = "wyoming-event"
event_command_str = shlex.join(settings.satellite.event_service_command)
with open(
SERVICES_DIR / f"{event_service}.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print("Description=Event service", file=service_file)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
print(f"ExecStart={event_command_str}", file=service_file)
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
satellite_command.extend(["--event-uri", "tcp://127.0.0.1:10500"])
satellite_requires.append(f"{event_service}.service")
if settings.satellite.debug:
satellite_command.extend(
["--debug", "--debug-recording-dir", str(LOCAL_DIR / "debug-recording")]
)
satellite_command_str = shlex.join(satellite_command)
with open(
SERVICES_DIR / "wyoming-satellite.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print("Description=Wyoming Satellite", file=service_file)
print("Wants=network-online.target", file=service_file)
print("After=network-online.target", file=service_file)
for requires in satellite_requires:
print(f"Requires={requires}", file=service_file)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
# For PulseAudio
print(f"Environment=XDG_RUNTIME_DIR=/run/user/{user_id}", file=service_file)
print(f"ExecStart={satellite_command_str}", file=service_file)
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
def install_services(settings: Settings, password: str):
# Install and run
installed_services = ["satellite"]
if settings.satellite.type == SatelliteType.WAKE:
assert settings.wake.system is not None, "No wake word system"
installed_services.append("wakeword")
if settings.satellite.event_service_command:
installed_services.append("event")
install_commands = []
for service in installed_services:
service_filename = f"wyoming-{service}.service"
install_commands.append(
[
"sudo",
"-S",
"cp",
str(SERVICES_DIR / service_filename),
"/etc/systemd/system/",
]
)
install_commands.append(["sudo", "-S", "systemctl", "daemon-reload"])
# Copy first, then enable and start
for service in installed_services:
install_commands.append(["sudo", "-S", "systemctl", "enable", service_filename])
install_commands.append(
["sudo", "-S", "systemctl", "enable", "--now", "wyoming-satellite.service"]
)
success = run_with_gauge(
"Installing Services...", install_commands, sudo_password=password
)
if success:
msgbox("Successfully installed services")
else:
error("installing services")
-150
View File
@@ -1,150 +0,0 @@
"""Speaker settings."""
import logging
import subprocess
from typing import List, Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger()
def configure_speakers(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = speakers_menu(choice)
if choice == "play":
if settings.snd.device is None:
msgbox("No speaker device set")
continue
test_sound_device(settings.snd.device)
elif choice == "test":
sound_device = test_speakers()
if sound_device is not None:
settings.snd.device = sound_device
settings.save()
elif choice == "list":
sound_device = radiolist(
"Select ALSA Device:",
get_sound_devices(),
settings.snd.device,
)
if sound_device:
settings.snd.device = sound_device
settings.save()
elif choice == "manual":
sound_device = inputbox("Enter ALSA Device:", settings.snd.device)
if sound_device:
settings.snd.device = sound_device
settings.save()
elif choice == "disable":
settings.snd.device = None
settings.save()
msgbox("Sound disabled")
elif choice == "feedback":
feedback_sounds = checklist(
"Enabled Sounds:",
[("awake", "On wake-up"), ("done", "After voice command")],
settings.snd.feedback_sounds,
)
if feedback_sounds is not None:
settings.snd.feedback_sounds = feedback_sounds
settings.save()
elif choice == "multiplier":
while True:
volume_multiplier = inputbox(
"Volume Multiplier (1 = default)", settings.snd.volume_multiplier
)
if volume_multiplier is None:
break
try:
volume_multiplier_float = float(volume_multiplier)
except ValueError:
msgbox("Invalid value")
continue
if volume_multiplier_float > 0:
settings.snd.volume_multiplier = volume_multiplier_float
settings.save()
break
msgbox("Must be > 0")
else:
break
def speakers_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Speakers",
[
("play", "Play Sound"),
("test", "Test All Speakers"),
("list", "Select From List"),
("manual", "Enter Manually"),
("disable", "Disable Sound"),
("multiplier", "Volume Multiplier"),
("feedback", "Toggle Feedback Sounds"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def get_sound_devices() -> List[str]:
devices = []
lines = subprocess.check_output(["aplay", "-L"]).decode("utf-8").splitlines()
for line in lines:
line = line.strip()
# default = PulseAudio
if (line == "default") or line.startswith("plughw:"):
devices.append(line)
return devices
def test_speakers() -> Optional[str]:
devices = get_sound_devices()
if not devices:
msgbox("No speakers found")
return None
device = devices.pop()
while True:
choice = menu(
f"Device: {device}",
[
("play", "Play Sound"),
("next", "Next Device"),
("choose", "Choose This Device"),
],
)
if choice == "play":
test_sound_device(device)
elif choice == "next":
if not devices:
msgbox("No more devices")
break
device = devices.pop()
elif choice == "choose":
return device
else:
break
return None
def test_sound_device(device: str) -> None:
try:
subprocess.check_call(
["aplay", "-q", "-D", device, str(PROGRAM_DIR / "sounds" / "awake.wav")]
)
except Exception:
_LOGGER.exception("Error testing device: %s", device)
-468
View File
@@ -1,468 +0,0 @@
"""Wake word settings."""
import itertools
import re
import shutil
from pathlib import Path
from typing import Dict, Optional
from .const import LOCAL_DIR, SatelliteType, Settings, WakeWordSystem
from .packages import install_packages, packages_installed
from .whiptail import (
error,
inputbox,
menu,
msgbox,
passwordbox,
radiolist,
run_with_gauge,
yesno,
)
def configure_wake_word(settings: Settings) -> None:
if settings.satellite.type != SatelliteType.WAKE:
if not yesno("Set satellite type to local wake word?"):
return
settings.satellite.type = SatelliteType.WAKE
settings.save()
def star(ww_system: WakeWordSystem) -> str:
return (
f"{ww_system.value} [*]"
if settings.wake.system == ww_system
else ww_system.value
)
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word",
[
("system", "Wake Word System"),
("wake_word", "Choose Wake Word"),
("openWakeWord", star(WakeWordSystem.OPENWAKEWORD)),
("porcupine1", star(WakeWordSystem.PORCUPINE1)),
("snowboy", star(WakeWordSystem.SNOWBOY)),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "system":
wake_word_system = radiolist(
"Wake Word System:",
[v.value for v in WakeWordSystem],
settings.wake.system,
)
if wake_word_system is None:
continue
wake_word_system = WakeWordSystem(wake_word_system)
install_wake_word(settings, wake_word_system)
elif choice == "wake_word":
select_wake_word(settings)
elif choice == "openWakeWord":
configure_openWakeWord(settings)
elif choice == "porcupine1":
configure_porcupine1(settings)
elif choice == "snowboy":
configure_snowboy(settings)
else:
break
def install_wake_word(settings: Settings, wake_word_system: WakeWordSystem) -> None:
if wake_word_system == WakeWordSystem.OPENWAKEWORD:
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
if not oww_dir.exists():
if not yesno("Install openWakeWord?"):
return
success = run_with_gauge(
"Installing openWakeWord",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-openwakeword.git",
str(oww_dir),
],
[str(oww_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(oww_dir)
except Exception:
pass
error("installing openWakeWord")
return
msgbox("openWakeWord installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
if wake_word_system == WakeWordSystem.PORCUPINE1:
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
if not porcupine1_dir.exists():
if not yesno("Install porcupine1?"):
return
success = run_with_gauge(
"Installing porcupine1",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-porcupine1.git",
str(porcupine1_dir),
],
[str(porcupine1_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(porcupine1_dir)
except Exception:
pass
error("installing porcupine1")
return
msgbox("porcupine1 installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
if wake_word_system == WakeWordSystem.SNOWBOY:
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
if not snowboy_dir.exists():
if not yesno("Install snowboy?"):
return
snowboy_packages = ["python3-dev", "swig", "libatlas-base-dev"]
if not packages_installed(*snowboy_packages):
password = passwordbox("sudo password:")
if not password:
return
success = install_packages(
"Installing system packages...", password, *snowboy_packages
)
if not success:
error("installing " + ", ".join(snowboy_packages))
return
success = run_with_gauge(
"Installing snowboy",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-snowboy.git",
str(snowboy_dir),
],
[str(snowboy_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(snowboy_dir)
except Exception:
pass
error("installing snowboy")
return
msgbox("snowboy installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
def select_wake_word(settings: Settings) -> None:
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
if not oww_dir.exists():
msgbox("openWakeWord is not installed")
return
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "openWakeWord"
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
while True:
ww_paths: Dict[str, Path] = {
p.stem: p for p in custom_wake_word_dir.glob("*.tflite")
}
for ww_path in community_wake_word_dir.rglob("*.tflite"):
ww_name = ww_path.stem
if ww_name in ww_paths:
continue
ww_paths[ww_name] = ww_path
for ww_path in (oww_dir / "wyoming_openwakeword" / "models").glob(
"*.tflite"
):
if not re.match("^.+_v[0-9].*$", ww_path.stem):
continue
ww_name = ww_path.stem.rsplit("_", maxsplit=1)[0]
if ww_name in ww_paths:
continue
ww_paths[ww_name] = ww_path
items = sorted(list(ww_paths.keys()))
wake_word = radiolist(
"Wake Word:", items, settings.wake.openwakeword.wake_word
)
if wake_word is None:
break
wake_word_path = ww_paths[wake_word]
if wake_word_path.is_relative_to(community_wake_word_dir):
# Copy to custom directory
shutil.copy(wake_word_path, custom_wake_word_dir)
settings.wake.openwakeword.wake_word = wake_word
settings.save()
break
return
if settings.wake.system == WakeWordSystem.PORCUPINE1:
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
if not porcupine1_dir.exists():
msgbox("porcupine1 is not installed")
return
ww_names = sorted(
list(
set(
p.stem.rsplit("_", maxsplit=1)[0]
for p in (
porcupine1_dir / "wyoming_porcupine1" / "data" / "resources"
).rglob("*.ppn")
)
)
)
wake_word = radiolist(
"Wake Word:", ww_names, settings.wake.porcupine1.wake_word
)
if wake_word is not None:
settings.wake.porcupine1.wake_word = wake_word
settings.save()
return
if settings.wake.system == WakeWordSystem.SNOWBOY:
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
if not snowboy_dir.exists():
msgbox("snowboy is not installed")
return
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "snowboy"
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
builtin_wake_words = (snowboy_dir / "wyoming_snowboy" / "data").glob("*.umdl")
custom_wake_words = itertools.chain(
custom_wake_word_dir.glob("*.pmdl"), custom_wake_word_dir.glob("*.umdl")
)
ww_names = sorted(
list(
set(
p.stem
for p in itertools.chain(builtin_wake_words, custom_wake_words)
)
)
)
wake_word = radiolist("Wake Word:", ww_names, settings.wake.snowboy.wake_word)
if wake_word is not None:
settings.wake.snowboy.wake_word = wake_word
settings.save()
return
def configure_openWakeWord(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > openWakeWord",
[
("community", "Download Community Wake Words"),
("threshold", "Set Threshold"),
("trigger_level", "Set Trigger Level"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "community":
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
if not community_wake_word_dir.exists():
success = run_with_gauge(
"Downloading community wake words...",
[
[
"git",
"clone",
"https://github.com/fwartner/home-assistant-wakewords-collection.git",
str(community_wake_word_dir),
]
],
)
if not success:
error("downloading community wake words")
else:
success = run_with_gauge(
"Updating community wake words...",
[
[
"git",
"-C",
str(community_wake_word_dir),
"pull",
"origin",
"main",
]
],
)
if not success:
error("updating community wake words")
elif choice == "threshold":
while True:
threshold = inputbox(
"Threshold (0-1, 0.5 = default):",
settings.wake.openwakeword.threshold,
)
if threshold is None:
break
try:
threshold_float = float(threshold)
except ValueError:
msgbox("Invalid value")
continue
if 0 < threshold_float < 1:
settings.wake.openwakeword.threshold = threshold_float
settings.save()
break
msgbox("Threshold must be in (0, 1)")
elif choice == "trigger_level":
while True:
trigger_level = inputbox(
"Trigger Level (> 0, 1 = default):",
settings.wake.openwakeword.trigger_level,
)
if trigger_level is None:
break
try:
trigger_level_int = int(trigger_level)
except ValueError:
msgbox("Invalid value")
continue
if trigger_level_int > 0:
settings.wake.openwakeword.trigger_level = trigger_level_int
settings.save()
break
msgbox("Trigger level must be > 0")
else:
break
def configure_porcupine1(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > porcupine1",
[
("sensitivity", "Set Sensitivity"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "sensitivity":
while True:
sensitivity = inputbox(
"Sensitivity (0-1, 0.5 = default):",
settings.wake.porcupine1.sensitivity,
)
if sensitivity is None:
break
try:
sensitivity_float = float(sensitivity)
except ValueError:
msgbox("Invalid value")
continue
if 0 < sensitivity_float < 1:
settings.wake.porcupine1.sensitivity = sensitivity_float
settings.save()
break
msgbox("Sensitivity must be in (0, 1)")
else:
break
def configure_snowboy(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > snowboy",
[
("sensitivity", "Set Sensitivity"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "sensitivity":
while True:
sensitivity = inputbox(
"Sensitivity (0-1, 0.5 = default):",
settings.wake.snowboy.sensitivity,
)
if sensitivity is None:
break
try:
sensitivity_float = float(sensitivity)
except ValueError:
msgbox("Invalid value")
continue
if 0 < sensitivity_float < 1:
settings.wake.snowboy.sensitivity = sensitivity_float
settings.save()
break
msgbox("Sensitivity must be in (0, 1)")
else:
break
-253
View File
@@ -1,253 +0,0 @@
"""Python interface to whiptail command."""
import logging
import shlex
import subprocess
import time
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional, Tuple, Union
from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
ItemType = Union[str, Tuple[Any, str]]
_LOGGER = logging.getLogger()
def whiptail(*args) -> Optional[str]:
proc = subprocess.Popen(
["whiptail", "--title", TITLE] + list(args),
stderr=subprocess.PIPE,
)
_stdout, stderr = proc.communicate()
if proc.returncode != 0:
return None
return stderr.decode("utf-8")
def menu(
text: str,
items: Sequence[ItemType],
selected_item: Optional[str] = None,
menu_args: Optional[Sequence[str]] = None,
) -> Optional[str]:
assert items, "No items"
item_map: Dict[Optional[str], str] = {}
item_args: List[str] = []
selected_tag: Optional[str] = None
for i, item in enumerate(items):
item_id = str(i)
item_args.append(item_id)
if isinstance(item, str):
item_map[item_id] = item
item_args.append(item)
if selected_item == item:
selected_tag = item_id
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if selected_item == item[0]:
selected_tag = item_id
menu_args = list(menu_args) if menu_args is not None else []
if selected_tag is not None:
menu_args.extend(["--default-item", selected_tag])
result = whiptail(
"--notags", *menu_args, "--menu", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
return item_map.get(result)
def inputbox(text: str, init: Optional[Any] = None) -> Optional[str]:
return whiptail(
"--inputbox", text, HEIGHT, WIDTH, str(init) if init is not None else ""
)
def passwordbox(text: str) -> Optional[str]:
return whiptail("--passwordbox", text, HEIGHT, WIDTH)
def radiolist(
text: str,
items: Sequence[ItemType],
selected_item: Any,
*args,
) -> Optional[Any]:
assert items, "No items"
item_map: Dict[str, ItemType] = {}
item_args: List[str] = []
for i, item in enumerate(items):
item_id = str(i)
item_args.append(item_id)
if isinstance(item, str):
item_map[item_id] = item
item_args.append(item)
if item == selected_item:
item_args.append("1")
else:
item_args.append("0")
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if item[0] == selected_item:
item_args.append("1")
else:
item_args.append("0")
result = whiptail(
"--notags", *args, "--radiolist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
if result is None:
return None
return item_map.get(result, result)
def checklist(
text: str,
items: Sequence[ItemType],
selected_items: Sequence[Any],
*args,
) -> Optional[List[Any]]:
assert items, "No items"
item_map: Dict[str, ItemType] = {}
item_args: List[str] = []
for i, item in enumerate(items):
item_id = str(i)
item_args.append(item_id)
if isinstance(item, str):
item_map[item_id] = item
item_args.append(item)
if item in selected_items:
item_args.append("1")
else:
item_args.append("0")
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if item[0] in selected_items:
item_args.append("1")
else:
item_args.append("0")
result = whiptail(
"--notags", *args, "--checklist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
if result is None:
return None
return [
item_map.get(result_item, result_item) for result_item in shlex.split(result)
]
def yesno(text: str) -> bool:
return whiptail("--yesno", text, HEIGHT, WIDTH) is not None
def msgbox(text: str) -> None:
whiptail("--msgbox", text, HEIGHT, WIDTH)
def gauge(text: str, seconds: int, parts: int = 20) -> None:
proc = subprocess.Popen(
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert proc.stdin is not None
percent = 0
while percent <= 100:
time.sleep(seconds / parts)
percent += int(100 / parts)
print(percent, file=proc.stdin, flush=True)
proc.communicate()
def error(reason: str) -> None:
msgbox(
f"An error occurred while {reason}.\n" "See local/installer.log for details."
)
# -----------------------------------------------------------------------------
def run_with_gauge(
text: str, commands: Sequence[Sequence[str]], sudo_password: Optional[str] = None
) -> bool:
proc = subprocess.Popen(
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert proc.stdin is not None
percent = 0
seconds = 5
parts = 20
with ThreadPoolExecutor() as executor:
for command in commands:
future = executor.submit(_run_command, command, sudo_password)
while not future.done():
time.sleep(seconds / parts)
percent += int(100 / parts)
if percent > 100:
percent = 0
print(percent, file=proc.stdin, flush=True)
if not future.result():
# Error occurred
return False
proc.communicate()
return True
def _run_command(command: Sequence[str], sudo_password: Optional[str] = None) -> bool:
try:
assert command
proc_input: Optional[str] = None
if (command[0] == "sudo") and (sudo_password is not None):
proc_input = sudo_password
proc = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
_stdout, stderr = proc.communicate(proc_input)
if proc.returncode != 0:
_LOGGER.error("Error running command: %s", command)
_LOGGER.error(stderr)
return False
except Exception:
_LOGGER.exception("Error running command: %s", command)
return False
return True
-5
View File
@@ -1,5 +0,0 @@
[mypy]
ignore_missing_imports = true
[mypy-setuptools.*]
ignore_missing_imports = True
-37
View File
@@ -1,37 +0,0 @@
[MESSAGES CONTROL]
disable=
format,
abstract-method,
cyclic-import,
duplicate-code,
global-statement,
import-outside-toplevel,
inconsistent-return-statements,
locally-disabled,
not-context-manager,
too-few-public-methods,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
too-many-boolean-expressions,
unnecessary-pass,
unused-argument,
broad-except,
too-many-nested-blocks,
invalid-name,
unused-import,
fixme,
useless-super-delegation,
missing-module-docstring,
missing-class-docstring,
missing-function-docstring,
import-error,
consider-using-with
[FORMAT]
expected-line-ending-format=LF
-61
View File
@@ -1,61 +0,0 @@
[build-system]
requires = ["setuptools>=62.3"]
build-backend = "setuptools.build_meta"
[project]
name = "wyoming_satellite"
version = "1.4.1"
license = {text = "MIT"}
description = "Wyoming server for remote voice satellite"
readme = "README.md"
authors = [
{name = "Michael Hansen", email = "mike@rhasspy.org"}
]
keywords = ["rhasspy", "wyoming", "satellite"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.9.0"
dependencies = [
"wyoming==1.5.4",
"zeroconf==0.88.0",
"pyring-buffer>=1,<2",
]
[project.optional-dependencies]
all = ["pysilero-vad==1.0.0", "webrtc-noise-gain==1.2.3"]
silerovad = ["pysilero-vad==1.0.0"]
webrtc = ["webrtc-noise-gain==1.2.3"]
respeaker = ["gpiozero", "spidev"]
dev = [
"black==22.12.0",
"flake8==6.0.0",
"isort==5.11.3",
"mypy==0.991",
"pylint==2.15.9",
"pytest==7.4.3",
"pytest-asyncio==0.23.3",
"build==1.2.2"
]
[project.scripts]
wyoming-satellite = "wyoming_satellite:__main__.run"
[project.urls]
"Source Code" = "http://github.com/rhasspy/wyoming-satellite"
[tool.setuptools]
platforms = ["any"]
zip-safe = true
include-package-data = true
[tool.setuptools.packages.find]
include = ["wyoming_satellite*"]
exclude = ["tests", "tests.*"]
-30
View File
@@ -1,30 +0,0 @@
certifi==2026.4.22
cffi==2.0.0
charset-normalizer==3.4.7
flatbuffers==20181003210633
idna==3.15
ifaddr==0.2.0
importlib_resources==7.1.0
libusb-package==1.0.26.3
numpy==1.25.2
onnxruntime==1.26.0
packaging==26.2
paho-mqtt==2.1.0
protobuf==7.34.1
psutil==7.2.2
pycparser==3.0
pyring_buffer==1.1.0
pysilero-vad==1.0.0
pyusb==1.3.1
PyYAML==6.0.3
requests==2.34.0
srt==3.5.3
tqdm==4.67.3
urllib3==2.7.0
vosk==0.3.45
webrtc-noise-gain==1.2.3
webrtcvad==2.0.10
websockets==16.0
wyoming==1.5.4
-e git+ssh://gitea@192.168.200.208/sirmrak/satellite.git@8f2ff703db66aab33d0f8f43919a1ba5e92d5439#egg=wyoming_satellite
zeroconf==0.88.0
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
_TESTS_DIR = _PROGRAM_DIR / "tests"
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
_EXAMPLES_DIR = _PROGRAM_DIR / "examples"
_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR, _EXAMPLES_DIR]
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS)
subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS)
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
_TESTS_DIR = _PROGRAM_DIR / "tests"
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR]
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS)
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "build", "--wheel", "--sdist"])
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "wyoming_satellite"] + sys.argv[1:])
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call(
[context.env_exe, str(_PROGRAM_DIR / "examples" / "2mic_service.py")]
+ sys.argv[1:]
)
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call(
[context.env_exe, str(_PROGRAM_DIR / "examples" / "4mic_service.py")]
+ sys.argv[1:]
)
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call(
[context.env_exe, str(_PROGRAM_DIR / "examples" / "usbmic_service.py")]
+ sys.argv[1:]
)
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
import argparse
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
parser = argparse.ArgumentParser()
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
args = parser.parse_args()
# Create virtual environment
builder = venv.EnvBuilder(with_pip=True)
context = builder.ensure_directories(_VENV_DIR)
builder.create(_VENV_DIR)
# Upgrade dependencies
pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
# Install requirements
subprocess.check_call(
pip
+ [
"install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
"-e",
str(_PROGRAM_DIR),
]
)
if args.dev:
# Install dev requirements
subprocess.check_call(
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
)
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_TEST_DIR = _PROGRAM_DIR / "tests"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
#
# Глобальный wrapper для Python из venv Satellite
# Используется в systemd сервисах
#
VENV_PATH="/home/sirmrak/projects/.venv_satellite"
# Проверяем существование venv
if [ ! -f "${VENV_PATH}/bin/python3" ]; then
echo "❌ VENV не найден: ${VENV_PATH}" >&2
exit 1
fi
exec "${VENV_PATH}/bin/python3" "$@"
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
#
# Быстрый запуск TEST satellite в tmux
# Не требует установки systemd сервисов
# Production сервисы (wake word, event) должны быть запущены отдельно
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_PATH="${SCRIPT_DIR}/.venv"
PYTHON="${VENV_PATH}/bin/python3"
# Проверка venv
if [ ! -f "$PYTHON" ]; then
echo "❌ Виртуальное окружение не найдено: $VENV_PATH"
echo "💡 Запустите: cd ${SCRIPT_DIR} && ./setup_dev.sh"
exit 1
fi
echo "🚀 Запуск TEST satellite в tmux..."
echo "=================================="
echo ""
# Проверка tmux
if ! command -v tmux &> /dev/null; then
echo "❌ tmux не найден!"
echo "💡 Установите: sudo apt-get install tmux"
exit 1
fi
# Очистка старой сессии если существует
tmux kill-session -t satellite-test 2>/dev/null || true
# Создание новой сессии
echo "📡 Создание сессии 'satellite-test'..."
# Satellite (порт 10702 + WebSocket)
tmux new-session -d -s satellite-test -n "satellite" \
"cd ${SCRIPT_DIR} && source ${VENV_PATH}/bin/activate && \
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10702 \
--name 'Wyoming Satellite Test' \
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' \
--event-uri tcp://127.0.0.1:10500 \
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name 'привет дройд' \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled \
--audio-level-interval-ms 100 \
--debug"
echo " ✅ Satellite запущен (порт 10702 + WebSocket :8675)"
echo ""
echo "💡 Production сервисы должны быть запущены:"
echo " - Wake Word (:10400): sudo systemctl status wyoming-wakeword-vosk.service"
echo " - Event (:10500): sudo systemctl status wyoming-event.service"
echo ""
# Возврат к окну
tmux select-window -t satellite-test:0
echo "✅ Запуск завершён!"
echo ""
echo "📋 Сессия tmux: 'satellite-test'"
echo ""
echo "🎮 Управление tmux:"
echo " tmux attach -t satellite-test # Подключиться"
echo " tmux kill-session -t satellite-test # Остановить"
echo ""
echo "🌐 WebSocket:"
echo " ws://127.0.0.1:8675"
echo ""
echo "💡 Пример подключения:"
echo " source ${VENV_PATH}/bin/activate"
echo " python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675"
echo ""
@@ -1,31 +0,0 @@
[Unit]
Description=Wyoming Satellite Test (dev branch)
Requires=network.target
[Service]
Type=simple
User=__CURRENT_USER__
WorkingDirectory=__SAT_BASE_DIR__
# Запуск satellite с WebSocket и Audio Level
# Порт 10702 чтобы не конфликтовать с production (10700) и старым led (10701)
# Для wake word и event используются production сервисы (:10400, :10500)
ExecStart=__SAT_VENV__/bin/python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10702 \
--name 'Wyoming Satellite Test' \
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' \
--event-uri tcp://127.0.0.1:10500 \
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name "привет дройд" \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled \
--audio-level-interval-ms 100 \
--debug
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
-37
View File
@@ -1,37 +0,0 @@
[Unit]
Description=Wyoming Event Service with LED control
After=network.target
[Service]
Type=simple
# 🔹 Запуск от имени вашего пользователя
User=sirmrak
Group=sirmrak
# 🔹 Рабочая директория — где лежит wyoming-led.py
WorkingDirectory=__SAT_BASE_DIR__/examples
# 🔹 Запуск через wrapper (путь подставляется при установке)
ExecStart=satellite-python __SAT_BASE_DIR__/examples/wyoming-led.py --host 127.0.0.1 --port 10500
# 🔹 Критически важно: передаём окружение
Environment="HOME=__HOME_DIR__"
Environment="USER=__CURRENT_USER__"
Environment="PATH=__SAT_VENV__/bin:/usr/local/bin:/usr/bin:/bin"
Environment="VIRTUAL_ENV=__SAT_VENV__"
# 🔹 Разрешаем доступ к устройству (на всякий случай)
PrivateDevices=false
# 🔹 Перезапуск при ошибке
Restart=on-failure
RestartSec=3
# 🔹 Логирование в journal
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wyoming-event
[Install]
WantedBy=multi-user.target
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Wyoming Satellite Service (Wake Mode)
Requires=network.target
[Service]
Type=simple
User=__CURRENT_USER__
WorkingDirectory=__SAT_BASE_DIR__
ExecStart=__SAT_BASE_DIR__/start_satellite.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
-18
View File
@@ -1,18 +0,0 @@
[Unit]
Description=voskWakeWord
[Service]
Type=simple
User=__CURRENT_USER__
ExecStart=satellite-python __SAT_BASE_DIR__/local/vosk-wake-dev/script/run \
--uri tcp://0.0.0.0:10400 \
--model-path /home/__CURRENT_USER__/projects/models/vosk-model-small-ru-0.22 \
--rate 16000 \
--wake-word "привет дройд"
WorkingDirectory=__SAT_BASE_DIR__/local/vosk-wake-dev
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
-22
View File
@@ -1,22 +0,0 @@
[flake8]
# To work with Black
max-line-length = 88
# E501: line too long
# W503: Line break occurred before a binary operator
# E203: Whitespace before ':'
# D202 No blank lines allowed after function docstring
# W504 line break after binary operator
ignore =
E501,
W503,
E203,
D202,
W504
[isort]
multi_line_output = 3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
indent = " "
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
VENV_PATH="${SCRIPT_DIR}/.venv"
echo " Настройка Python окружения..."
# Проверка Python
if ! command -v python3 &> /dev/null; then
echo "❌ Python3 не найден!" >&2
exit 1
fi
PYTHON_VERSION=$(python3 --version)
echo "✅ ${PYTHON_VERSION}"
# Создание venv
if [ -d "${VENV_PATH}" ]; then
echo "⚠️ .venv/ уже существует, пропускаю создание"
else
echo "📦 Создание виртуального окружения..."
python3 -m venv "${VENV_PATH}"
fi
# Активация и установка зависимостей
echo "📥 Установка зависимостей..."
"${VENV_PATH}/bin/pip" install --upgrade pip
"${VENV_PATH}/bin/pip" install -r "${SCRIPT_DIR}/requirements.txt"
echo ""
echo "✅ Готово!"
echo ""
echo "🚀 Запуск:"
echo " ./start_satellite.sh [wake|vad]"
echo ""
echo "🔧 Активация вручную:"
echo " source .venv/bin/activate"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# Путь к Python-окружению
VENV_PATH=""
for candidate in \
"${HOME}/projects/.venv_satellite" \
"${HOME}/.venv_satellite" \
"${SCRIPT_DIR}/.venv" \
"${SCRIPT_DIR}/.venv_satellite"
do
if [ -f "${candidate}/bin/python3" ]; then
VENV_PATH="${candidate}"
break
fi
done
if [ -z "$VENV_PATH" ]; then
echo "❌ Виртуальное окружение не найдено!" >&2
exit 1
fi
PY="${VENV_PATH}/bin/python3"
LED_SCRIPT="${SCRIPT_DIR}/examples/wyoming-led.py"
echo "🚀 Запуск LED сервиса..."
echo "📡 Wyoming Satellite: tcp://0.0.0.0:10701"
echo "📡 MQTT Broker: 192.168.200.100:1883"
exec "$PY" "$LED_SCRIPT" --host 0.0.0.0 --port 10701
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Делаем текущий проект доступным для Python
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
export PYTHONPATH="${SCRIPT_DIR}"
# Путь к Python-окружению
# Приоритет: .venv/ в корне проекта (локальное окружение)
VENV_PATH="${SCRIPT_DIR}/.venv"
if [ ! -f "${VENV_PATH}/bin/python3" ]; then
echo "❌ Виртуальное окружение не найдено: ${VENV_PATH}" >&2
echo "💡 Создайте: python3 -m venv ${VENV_PATH} && source ${VENV_PATH}/bin/activate && pip install -r requirements.txt" >&2
exit 1
fi
PY="${VENV_PATH}/bin/python3"
# Режим запуска: wake|vad (по умолчанию wake)
MODE="${1:-wake}"
BASE_ARGS=(
--debug
--name 'Wyoming Pi Vosk Satellite'
--uri tcp://0.0.0.0:10700
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw'
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw'
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
#--mic-noise-suppression 2
#--mic-auto-gain 20
--event-uri tcp://127.0.0.1:10500
)
if [[ "$MODE" == "wake" ]]; then
# Локальный wakeword-сервис (Vosk). VAD в этом режиме не используется спутником.
EXTRA_ARGS=(
--wake-uri tcp://127.0.0.1:10400
--wake-word-name "привет дроид" \
#--wake-word-name "привет придурок" \
#--wake-word-name "привет жопа с ушами"
# Добавьте дополнительные фразы при необходимости:
# --wake-word-name "привет дурик" \
# --wake-word-name "привет говнюк"
)
elif [[ "$MODE" == "vad" ]]; then
# Локальный VAD (WebRTC+энергия) без wakeword-сервиса
EXTRA_ARGS=(
--vad
--vad-system webrtc
--vad-trigger-level 5 # ≈100мс при 20мс окне
--vad-energy-threshold 200
--vad-buffer-seconds 1
--vad-wake-word-timeout 5
)
else
echo "Неизвестный режим: $MODE (ожидается: wake|vad)" >&2
exit 1
fi
exec "$PY" -m wyoming_satellite "${BASE_ARGS[@]}" "${EXTRA_ARGS[@]}"
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Делаем текущий проект доступным для Python
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
export PYTHONPATH="${SCRIPT_DIR}"
# Путь к Python-окружению
PY="/home/sirmrak/projects/.venv_satellite/bin/python"
# Режим запуска: wake|vad (по умолчанию wake)
MODE="${1:-wake}"
BASE_ARGS=(
--debug
--name 'Wyoming Pi Satellite Test'
--uri tcp://0.0.0.0:10701
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw'
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw'
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
#--mic-noise-suppression 2
#--mic-auto-gain 20
--event-uri tcp://127.0.0.1:10500
)
if [[ "$MODE" == "wake" ]]; then
# Локальный wakeword-сервис (Vosk). VAD в этом режиме не используется спутником.
EXTRA_ARGS=(
--wake-uri tcp://127.0.0.1:10400
#--wake-word-name "привет дроид" \
--wake-word-name "привет придурок" \
#--wake-word-name "привет жопа с ушами"
# Добавьте дополнительные фразы при необходимости:
# --wake-word-name "привет дурик" \
# --wake-word-name "привет говнюк"
)
elif [[ "$MODE" == "vad" ]]; then
# Локальный VAD (WebRTC+энергия) без wakeword-сервиса
EXTRA_ARGS=(
--vad
--vad-system webrtc
--vad-trigger-level 5 # ≈100мс при 20мс окне
--vad-energy-threshold 200
--vad-buffer-seconds 1
--vad-wake-word-timeout 5
)
else
echo "Неизвестный режим: $MODE (ожидается: wake|vad)" >&2
exit 1
fi
exec "$PY" -m wyoming_satellite "${BASE_ARGS[@]}" "${EXTRA_ARGS[@]}"
-1
View File
@@ -1 +0,0 @@
"""Wyoming satellite tests."""
-64
View File
@@ -1,64 +0,0 @@
"""Shared code for Wyoming satellite tests."""
import asyncio
import io
from collections.abc import Iterable
from typing import Optional
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event
AUDIO_START = AudioStart(rate=16000, width=2, channels=1)
AUDIO_STOP = AudioStop()
AUDIO_CHUNK = AudioChunk(
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
)
class FakeStreamReaderWriter:
def __init__(self) -> None:
self._undrained_data = bytes()
self._value = bytes()
self._data_ready = asyncio.Event()
def write(self, data: bytes) -> None:
self._undrained_data += data
def writelines(self, data: Iterable[bytes]) -> None:
for line in data:
self.write(line)
async def drain(self) -> None:
self._value += self._undrained_data
self._undrained_data = bytes()
self._data_ready.set()
self._data_ready.clear()
async def readline(self) -> bytes:
while b"\n" not in self._value:
await self._data_ready.wait()
with io.BytesIO(self._value) as value_io:
data = value_io.readline()
self._value = self._value[len(data) :]
return data
async def readexactly(self, n: int) -> bytes:
while len(self._value) < n:
await self._data_ready.wait()
data = self._value[:n]
self._value = self._value[n:]
return data
class MicClient(AsyncClient):
async def read_event(self) -> Optional[Event]:
# Send 30ms of audio every 30ms
await asyncio.sleep(AUDIO_CHUNK.seconds)
return AUDIO_CHUNK.event()
async def write_event(self, event: Event) -> None:
# Output only
pass
-222
View File
@@ -1,222 +0,0 @@
import asyncio
import logging
from typing import Final, Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event, async_read_event
from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
from wyoming.tts import Synthesize
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import (
AUDIO_CHUNK,
AUDIO_START,
AUDIO_STOP,
FakeStreamReaderWriter,
MicClient,
)
_LOGGER = logging.getLogger()
TIMEOUT: Final = 1
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._event_ready = asyncio.Event()
self._event: Optional[Event] = None
self._detected: bool = False
async def read_event(self) -> Optional[Event]:
await self._event_ready.wait()
self._event_ready.clear()
return self._event
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
if not self._detected:
self._detected = True
self._event = Detection().event()
self._event_ready.set()
class SndClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
pass
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.detection = asyncio.Event()
self.streaming_started = asyncio.Event()
self.streaming_stopped = asyncio.Event()
self.transcript = asyncio.Event()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.detection.set()
elif StreamingStarted.is_type(event.type):
self.streaming_started.set()
elif StreamingStopped.is_type(event.type):
self.streaming_stopped.set()
elif Transcript.is_type(event.type):
self.transcript.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wake_satellite() -> None:
mic_client = MicClient()
snd_client = SndClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
return_value=snd_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
snd=SndSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
async def event_from_satellite() -> Optional[Event]:
return await async_read_event(server_io)
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
# Fake server connection
server_io = FakeStreamReaderWriter()
await satellite.set_server("test", server_io) # type: ignore
# Start satellite
await satellite.event_from_server(RunSatellite().event())
# Trigger detection
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert Detection.is_type(event.type), event
# Pipeline should start
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert RunPipeline.is_type(event.type), event
run_pipeline = RunPipeline.from_event(event)
assert run_pipeline.start_stage == PipelineStage.ASR
assert run_pipeline.end_stage == PipelineStage.TTS
# Event service should have received detection
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
# Server should be receiving audio now
assert satellite.is_streaming, "Not streaming"
for _ in range(5):
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming start
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
# Send transcript
await satellite.event_from_server(Transcript(text="test").event())
# Event service should have received transcript
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
# Wait for streaming to stop
while satellite.is_streaming:
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming stop
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
# Fake a TTS response
await satellite.event_from_server(Synthesize(text="test").event())
# Event service should have received synthesize
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
# Audio start, chunk, stop
await satellite.event_from_server(AUDIO_START.event())
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
# Event service does not get audio chunks, just start/stop
await satellite.event_from_server(AUDIO_CHUNK.event())
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
await satellite.event_from_server(AUDIO_STOP.event())
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
# Stop satellite
await satellite.stop()
await satellite_task
-97
View File
@@ -1,97 +0,0 @@
import asyncio
import logging
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk
from wyoming.client import AsyncClient
from wyoming.event import Event
from wyoming.satellite import RunSatellite
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import MicClient
_LOGGER = logging.getLogger()
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._detection_event = asyncio.Event()
async def read_event(self) -> Optional[Event]:
await self._detection_event.wait()
return Detection().event()
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self._detection_event.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.wake_event = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.wake_event.set()
@pytest.mark.asyncio
async def test_multiple_wakeups(tmp_path: Path) -> None:
mic_client = MicClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
# Fake server connection
satellite.server_id = "test"
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
await satellite.event_from_server(RunSatellite().event())
await asyncio.wait_for(event_client.wake_event.wait(), timeout=1)
event_client.wake_event.clear()
# Stop streaming
await satellite.event_from_server(Transcript("test").event())
# Should not trigger again within refractory period (default: 5 sec)
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(event_client.wake_event.wait(), timeout=0.15)
await satellite.stop()
await satellite_task
-14
View File
@@ -1,14 +0,0 @@
[tox]
env_list =
py{39,310,311,312,313}
minversion = 4.12.1
[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg
deps =
pytest>=7,<8
pytest-asyncio<1
commands =
pytest {tty:--color=yes} {posargs}
-187
View File
@@ -1,187 +0,0 @@
# 🛰️ LVA NEXUS — Web Client для Wyoming Satellite
Современный веб-интерфейс для управления Wyoming Satellite через WebSocket.
## 🚀 Быстрый старт
### 1. Запустить satellite с WebSocket
```bash
cd ~/projects/satellite-dev
source .venv/bin/activate
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled \
--debug
```
### 2. Запустить HTTP сервер с паролем
```bash
cd ~/projects/satellite-dev/web-client
# С паролем по умолчанию (admin)
python3 start_server.py --port 8080
# Или со своим паролем
python3 start_server.py --port 8080 --password mysecretpassword
```
### 3. Открыть в браузере
```
http://localhost:8080/client.html
или
http://192.168.200.111:8080/client.html
```
**🔐 Ввести пароль** (по умолчанию: `admin`)
---
## 📋 Функции
| Функция | Статус | Описание |
|---------|--------|----------|
| 🔊 Громкость | ✅ | Установка громкости satellite (0-100%) |
| 🔇 Mute / 🔊 Unmute | ✅ | Заглушить/включить микрофон |
| 📊 RMS Audio Level | ✅ | Визуализация громкости в реальном времени |
| 💡 LED управление | ⏳ | Цвет, яркость, эффекты |
| 🎤 Start Listening | ⏳ | Запуск прослушивания |
| ⏹️ Stop Pipeline | ⏳ | Остановка pipeline |
| ⏲️ Stop Timer | ⏳ | Остановка таймера |
| 🗣️ Transcript | ⏳ | Отображение распознанного текста |
---
## 🎮 Управление
### Громкость
- **Слайдер** — плавная регулировка 0-100%
- **Кнопки VOL+ / VOL-** — пошаговое изменение
- **Mute/Unmute** — быстрое отключение микрофона
### LED управление
```javascript
// Яркость
sendCommand('set_led_brightness', { brightness: 200 });
// Цвет (RGB)
sendCommand('set_led_color', { r: 255, g: 0, b: 0 });
```
### Аудио визуализация
- **Canvas** — отрисовка аудио уровня в реальном времени
- **RMS метр** — числовое значение громкости
- **Орбита** — анимация состояния (idle, listening, speaking)
---
## 🔧 Настройки
### Подключение
При первом запуске введите WebSocket адрес:
```
ws://192.168.200.111:8675
```
### Авто-подключение
- **Auto-reconnect** — автоматическое переподключение при разрыве
- **Auto-scroll** — автоматическая прокрутка лога событий
---
## 📊 События
Web клиент получает от satellite:
| Событие | Описание |
|---------|----------|
| `voice-started` | Пользователь начал говорить |
| `voice-stopped` | Пользователь перестал говорить |
| `transcript` | Распознанный текст |
| `synthesize` | Запрос на синтез речи |
| `audio-level` | RMS громкость микрофона |
| `timer-started` | Таймер запущен |
| `timer-finished` | Таймер завершён |
| `error` | Ошибка |
---
## 🐛 Известные проблемы
| Проблема | Статус | Решение |
|----------|--------|---------|
| Громкость всегда 100% | ✅ Исправлено | Поддерживаются `level` и `volume` |
| WebSocket отключается | ⏳ В работе | Проверить firewall |
| RMS не обновляется | ⏳ В работе | Проверить audio-level-enabled |
---
## 🛠️ Разработка
### Структура
```
web-client/
├── client.html # Основной UI
├── README.md # Документация
└── start_server.py # HTTP сервер (опционально)
```
### Добавление команд
1. **В satellite.py** — обработчик команды:
```python
async def handle_websocket_command(self, command: str, data: dict):
if command == "my_command":
# обработка
```
2. **В client.html** — отправка команды:
```javascript
sendCommand('my_command', { param: value });
```
3. **В client.html** — обработка ответа:
```javascript
function handleIncomingMessage(ev) {
const json = JSON.parse(ev.data);
if(json.event === 'my_event') {
// обработка
}
}
```
---
## 📝 Changelog
### 2026-05-21
- ✅ Исправлен баг volume/level
- ✅ Добавлена поддержка обоих названий параметра
- ✅ Обновлён план разработки
### 2026-05-20
- ✅ Создан современный UI
- ✅ WebSocket интеграция
- ✅ RMS визуализация
- ✅ Лог событий
---
## 🔗 Связанное
- [WebSocket Server Plan](../docs/development/websocket-server-plan.md)
- [Wyoming Satellite](../README.md)
- [MQTT LED Integration](../docs/integration/mqtt-led.md)
File diff suppressed because it is too large Load Diff
Binary file not shown.
-396
View File
@@ -1,396 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
/* ===== СЛОЙ 1: BODY — общий фон страницы ===== */
body {
background: radial-gradient(circle at 30% 20%, #0a0f1e, #03060c);
font-family: 'Segoe UI', 'Inter', system-ui, sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
/* ===== Частицы ===== */
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
width: 2px;
height: 2px;
background: rgba(0, 255, 255, 0.3);
border-radius: 50%;
animation: float 20s infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-100vh) translateX(50px); opacity: 0; }
}
/* ===== Контейнер ассистента ===== */
.assistant-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
padding: 40px;
max-width: 600px;
width: 100%;
}
/* ===== Бейдж подключения ===== */
.connection-badge {
position: absolute;
top: 30px;
right: 30px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
padding: 12px 20px;
border-radius: 50px;
border: 1px solid rgba(0, 255, 255, 0.3);
font-size: 14px;
color: #b0e0ff;
font-weight: 500;
z-index: 2;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ff4d4d;
box-shadow: 0 0 10px #ff4d4d;
transition: all 0.3s;
}
.status-dot.connected {
background: #0ff;
box-shadow: 0 0 20px #0ff;
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
}
/* ===== СЛОЙ 3: ANIMATION-WRAPPER — непрозрачный тёмный круг =====
isolation: isolate создаёт локальный stacking context,
чтобы ::before с z-index: -1 был ПОД wrapper, но НАД body
*/
.animation-wrapper {
position: relative;
width: 420px;
height: 420px;
border-radius: 50%;
isolation: isolate;
/* CSS-переменные для фона круга и ореола */
--bg-light: #1a2540;
--bg-dark: #03060c;
--glow-color: rgba(59, 130, 246, 0.55);
/* Непрозрачный тёмный фон — свой для каждого состояния */
background: radial-gradient(
circle at 30% 30%,
var(--bg-light) 0%,
#0a0f1e 50%,
var(--bg-dark) 100%
);
/* Лёгкая внутренняя подсветка (inset) */
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.5);
transition: background 0.8s ease;
}
/* ===== СЛОЙ 2: ::BEFORE — свечение-ореол, выходит за границы ===== */
.animation-wrapper::before {
content: '';
position: absolute;
/* Выходим за границы круга на 70px с каждой стороны */
inset: -70px;
border-radius: 50%;
/* Светящийся ореол */
background: radial-gradient(
circle at center,
var(--glow-color) 0%,
var(--glow-color) 30%,
rgba(0, 0, 0, 0) 70%
);
filter: blur(25px);
opacity: 0.85;
/* ПОД wrapper, но НАД body благодаря isolation: isolate */
z-index: -1;
pointer-events: none;
transition: background 0.8s ease, opacity 0.8s ease;
animation: halo-pulse 3s ease-in-out infinite;
}
@keyframes halo-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
/* ===== СЛОЙ 4: LOTTIE-CANVAS — анимация с прозрачным фоном ===== */
#lottie-canvas {
width: 100% !important;
height: 100% !important;
display: block;
transform: scale(2);
transform-origin: center center;
position: relative;
z-index: 2;
/* Обрезаем canvas по кругу, чтобы не вылезал за wrapper */
clip-path: circle(50% at center);
/* Фон прозрачный — видно непрозрачный фон wrapper под ним */
background: transparent;
}
/* ===== ЦВЕТА ДЛЯ КАЖДОГО СОСТОЯНИЯ ===== */
.animation-wrapper.idle {
--bg-light: #1a2540;
--bg-dark: #03060c;
--glow-color: rgba(59, 130, 246, 0.55);
}
.animation-wrapper.yes {
--bg-light: #0f2818;
--bg-dark: #030a06;
--glow-color: rgba(34, 197, 94, 0.65);
}
.animation-wrapper.no {
--bg-light: #2a1010;
--bg-dark: #0a0303;
--glow-color: rgba(239, 68, 68, 0.7);
}
.animation-wrapper.no::before {
animation: halo-pulse 1s ease-in-out infinite;
}
.animation-wrapper.alert {
--bg-light: #2a1f08;
--bg-dark: #0a0803;
--glow-color: rgba(245, 158, 11, 0.7);
}
.animation-wrapper.alert::before {
animation: halo-pulse 0.5s ease-in-out infinite;
}
.animation-wrapper.thinking {
--bg-light: #1a0f2a;
--bg-dark: #06030a;
--glow-color: rgba(168, 85, 247, 0.65);
}
.animation-wrapper.thinking::before {
animation: halo-pulse 1.5s ease-in-out infinite;
}
.animation-wrapper.jump {
--bg-light: #2a0f1f;
--bg-dark: #0a0306;
--glow-color: rgba(236, 72, 153, 0.75);
}
.animation-wrapper.jump::before {
animation: halo-pulse 0.3s ease-in-out infinite;
}
/* ===== Оверлей загрузки ===== */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 6, 12, 0.95);
z-index: 10;
border-radius: 50%;
transition: opacity 0.5s;
}
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
/* ===== Текст статуса ===== */
.status-text {
font-size: 24px;
font-weight: 600;
color: #00ffff;
text-transform: uppercase;
letter-spacing: 4px;
text-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
animation: text-glow 2s ease-in-out infinite;
min-height: 30px;
}
@keyframes text-glow {
0%, 100% { text-shadow: 0 0 20px rgba(0, 255, 255, 0.5); }
50% { text-shadow: 0 0 40px rgba(0, 255, 255, 0.8); }
}
/* ===== Контейнер громкости ===== */
.volume-container {
width: 100%;
max-width: 400px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 255, 255, 0.3);
border-radius: 30px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.volume-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #b0e0ff;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 2px;
}
.volume-value {
font-size: 24px;
font-weight: 700;
color: #00ffff;
font-family: 'Courier New', monospace;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: rgba(0, 255, 255, 0.2);
border-radius: 10px;
outline: none;
transition: all 0.3s;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
background: #00ffff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
}
/* ===== Кнопки громкости ===== */
.vol-btn {
background: rgba(0, 255, 255, 0.1);
border: 1px solid rgba(0, 255, 255, 0.5);
color: #00ffff;
padding: 12px 24px;
border-radius: 50px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.vol-btn:hover {
background: rgba(0, 255, 255, 0.3);
border-color: #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
}
/* ===== Debug-кнопки ===== */
.debug-buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 15px;
}
.debug-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(0, 255, 255, 0.4);
color: #0ff;
padding: 8px 16px;
border-radius: 40px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
}
.debug-btn:hover {
background: rgba(0, 255, 255, 0.2);
border-color: #0ff;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.4);
}
/* ===== Transcript box ===== */
.transcript-box {
width: 100%;
max-width: 500px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(15px);
border: 1px solid rgba(0, 255, 255, 0.2);
border-radius: 20px;
padding: 20px;
min-height: 80px;
display: flex;
flex-direction: column;
gap: 10px;
}
.transcript-label {
font-size: 12px;
color: #7f9fcf;
text-transform: uppercase;
letter-spacing: 2px;
}
.transcript-text {
font-size: 18px;
color: #eef5ff;
font-style: italic;
line-height: 1.6;
}
/* ===== Адаптив ===== */
@media (max-width: 768px) {
.animation-wrapper { width: 280px; height: 280px; }
.status-text { font-size: 20px; }
}
-62
View File
@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Droid Assistant</title>
<!-- Стили -->
<link rel="stylesheet" href="droidAssist.css">
</head>
<body>
<div class="particles" id="particles"></div>
<div class="connection-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusLabel">Отключено</span>
</div>
<div class="assistant-container">
<div class="animation-wrapper idle" id="animationWrapper">
<canvas id="lottie-canvas" width="700" height="700"></canvas>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
</div>
<div class="status-text" id="statusText">В ОЖИДАНИИ</div>
<div class="volume-container">
<div class="volume-header">
<span>🔊 Громкость</span>
<span class="volume-value" id="volumeValue">80%</span>
</div>
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80" step="1">
<div class="volume-buttons">
<button class="vol-btn" id="volDownBtn">🔉 -10%</button>
<button class="vol-btn" id="volUpBtn">🔊 +10%</button>
</div>
</div>
<div class="debug-buttons">
<button class="debug-btn" onclick="goToState('yes')">👍 Yes</button>
<button class="debug-btn" onclick="goToState('thinking')">🤔 Think</button>
<button class="debug-btn" onclick="goToState('alert')">⚠️ Alert</button>
<button class="debug-btn" onclick="goToState('no')">❌ No</button>
<button class="debug-btn" onclick="goToState('jump')">🦘 Jump</button>
<button class="debug-btn" onclick="goToState('idle')">😐 Idle</button>
</div>
<div class="transcript-box">
<div class="transcript-label">💬 Последняя фраза</div>
<div class="transcript-text" id="transcriptText">—</div>
</div>
</div>
<!-- Скрипт как модуль (нужен для импорта DotLottie) -->
<script type="module" src="droidAssist.js"></script>
</body>
</html>
-244
View File
@@ -1,244 +0,0 @@
import { DotLottie } from 'https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.0/+esm';
// ==================== КОНФИГУРАЦИЯ ====================
const WS_URL = 'ws://192.168.200.111:8675';
const NUM_PARTICLES = 30;
const LOTTIE_SRC = 'animation.lottie';
// ==================== СЕГМЕНТЫ АНИМАЦИИ ====================
const segments = {
idle: { start: 0, end: 29 },
yes: { start: 31, end: 105 },
no: { start: 106, end: 180 },
alert: { start: 181, end: 270 },
thinking: { start: 271, end: 390 },
jump: { start: 391, end: 479 }
};
// ==================== СОСТОЯНИЕ ====================
let ws = null;
let isConnected = false;
let currentVolume = 0.8;
let dotLottie = null;
let currentState = 'idle';
let animationReady = false;
// ==================== DOM ====================
const statusDot = document.getElementById('statusDot');
const statusLabel = document.getElementById('statusLabel');
const wrapper = document.getElementById('animationWrapper');
const statusText = document.getElementById('statusText');
const volumeSlider = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const volUpBtn = document.getElementById('volUpBtn');
const volDownBtn = document.getElementById('volDownBtn');
const transcriptText = document.getElementById('transcriptText');
// ==================== ЧАСТИЦЫ ====================
function initParticles() {
const particles = document.getElementById('particles');
for (let i = 0; i < NUM_PARTICLES; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 20 + 's';
particle.style.animationDuration = (15 + Math.random() * 10) + 's';
particles.appendChild(particle);
}
}
// ==================== УПРАВЛЕНИЕ АНИМАЦИЕЙ ====================
window.goToState = function(stateName, statusLabelText) {
if (!animationReady || !dotLottie) return;
currentState = stateName;
const seg = segments[stateName];
if (!seg) return;
dotLottie.stop();
dotLottie.setSegment(seg.start, seg.end);
if (stateName === 'idle') {
dotLottie.setSpeed(0.1);
} else {
dotLottie.setSpeed(1);
}
dotLottie.setLoop(stateName === 'idle');
dotLottie.play();
if (statusLabelText !== undefined) {
statusText.textContent = statusLabelText;
}
// Обновляем класс для свечения фона
wrapper.className = `animation-wrapper ${stateName}`;
};
// ==================== МАППИНГ СОБЫТИЙ SATELLITE → АНИМАЦИЯ ====================
function handleSatelliteEvent(eventType, eventData) {
const data = eventData || {};
switch (eventType) {
// ─── yes: СЛУШАЮ, ГОВОРЮ, АКТИВЕН ───
case 'voice-started':
case 'stt-start':
goToState('yes', 'СЛУШАЮ');
break;
case 'tts-start':
case 'audio-start':
goToState('yes', 'ГОВОРЮ');
break;
case 'streaming-started':
goToState('yes', 'АКТИВЕН');
break;
// ─── thinking: ОБРАБОТКА ───
case 'voice-stopped':
case 'stt-stop':
goToState('thinking', 'ОБРАБОТКА');
break;
// ─── idle: В ОЖИДАНИИ ───
case 'played':
case 'tts-stop':
case 'audio-stop':
goToState('idle', 'В ОЖИДАНИИ');
break;
// ─── no: ОШИБКА, СПЛЮ ───
case 'satellite-disconnected':
case 'error':
goToState('no', 'ОШИБКА');
break;
case 'pause-satellite':
goToState('no', 'СПЛЮ...');
break;
// ─── jump: ПРИВЕТ!!! ───
case 'detection':
goToState('jump', 'ПРИВЕТ!!!');
break;
// ─── transcript / synthesize ───
case 'transcript':
if (data.text) {
transcriptText.textContent = `"${data.text}"`;
goToState('yes');
}
break;
case 'synthesize':
if (data.text) {
transcriptText.textContent = `"${data.text}"`;
goToState('yes');
}
break;
// ─── audio-level ───
case 'audio-level':
break;
default:
if (eventType !== 'audio-level') {
console.log('Unhandled event:', eventType, data);
}
break;
}
}
// ==================== WEBSOCKET ====================
function connectWebSocket() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
isConnected = true;
statusDot.classList.add('connected');
statusLabel.textContent = 'Подключено';
console.log('✅ WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const eventType = data.type || data.event;
const eventData = data.data || {};
if (eventType != "audio-level") {
console.info('EventType:', eventType);
console.info('EventData:', eventData);
}
handleSatelliteEvent(eventType, eventData);
} catch (e) {
console.error('Parse error:', e);
}
};
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onclose = () => {
isConnected = false;
statusDot.classList.remove('connected');
statusLabel.textContent = 'Отключено';
handleSatelliteEvent('satellite-disconnected');
console.log('❌ WebSocket disconnected');
setTimeout(connectWebSocket, 3000);
};
}
// ==================== ГРОМКОСТЬ ====================
function updateVolume(newVolume) {
currentVolume = Math.max(0, Math.min(1, newVolume));
volumeSlider.value = Math.round(currentVolume * 100);
volumeValue.textContent = Math.round(currentVolume * 100) + '%';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
command: 'set_volume',
data: { level: currentVolume }
}));
}
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
async function init() {
const canvas = document.getElementById('lottie-canvas');
const overlay = document.getElementById('loadingOverlay');
initParticles();
connectWebSocket();
dotLottie = new DotLottie({
canvas: canvas,
src: LOTTIE_SRC,
autoplay: false,
loop: true,
renderConfig: { preserveAspectRatio: 'xMidYMid meet' }
});
dotLottie.addEventListener('load', () => {
animationReady = true;
overlay.classList.add('hidden');
goToState('idle', 'В ОЖИДАНИИ');
});
dotLottie.addEventListener('complete', () => {
if (currentState !== 'idle') {
goToState('idle', 'В ОЖИДАНИИ');
}
});
// Volume controls
volumeSlider.addEventListener('input', (e) => updateVolume(e.target.value / 100));
volUpBtn.addEventListener('click', () => updateVolume(currentVolume + 0.1));
volDownBtn.addEventListener('click', () => updateVolume(currentVolume - 0.1));
}
// Запуск после загрузки страницы
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
-163
View File
@@ -1,163 +0,0 @@
#!/usr/bin/env python3
"""
Simple HTTP server for WebSocket client testing with password protection.
Usage:
python3 start_web_server.py [--port PORT] [--password PASSWORD]
Open in browser:
http://localhost:PORT/client.html
"""
import argparse
import base64
import http.server
import socketserver
import os
import sys
import threading
from pathlib import Path
PORT = 8080
SCRIPT_DIR = Path(__file__).parent.resolve()
# Default password
DEFAULT_PASSWORD = "admin"
class AuthHandler(http.server.SimpleHTTPRequestHandler):
"""HTTP handler with Basic Auth."""
def __init__(self, *args, password="admin", **kwargs):
self.password = password
super().__init__(*args, **kwargs)
def check_auth(self):
"""Check Basic Auth credentials."""
auth_header = self.headers.get('Authorization')
if not auth_header:
return False
try:
# Decode "Basic base64(username:password)"
auth_type, auth_string = auth_header.split(' ', 1)
if auth_type.lower() != 'basic':
return False
decoded = base64.b64decode(auth_string).decode('utf-8')
username, password = decoded.split(':', 1)
# For now, we only check password (username is ignored)
return password == self.password
except Exception:
return False
def send_auth_request(self):
"""Send 401 Unauthorized response."""
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm="Wyoming Satellite"')
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
html = '''
<html>
<head><title>401 Unauthorized</title></head>
<body>
<h1>Wyoming Satellite - Authentication Required</h1>
<p>Please enter the password to access the Web UI.</p>
</body>
</html>
'''
self.wfile.write(html.encode('utf-8'))
def do_GET(self):
"""Handle GET requests with authentication."""
# Skip auth for static files that are already protected
if not self.check_auth():
self.send_auth_request()
return
super().do_GET()
print(f"[WebServer] ✅ {self.requestline} → authenticated")
def log_message(self, format, *args):
# Suppress default logging
pass
def create_auth_handler(password):
"""Create auth handler with password."""
class Handler(AuthHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, password=password, **kwargs)
return Handler
class ReusableTCPServer(socketserver.TCPServer):
"""TCP Server with address reuse enabled."""
allow_reuse_address = True
def main():
parser = argparse.ArgumentParser(description='WebSocket Client Web Server with Password Protection')
parser.add_argument('--port', type=int, default=PORT, help=f'Port (default: {PORT})')
parser.add_argument('--host', default='0.0.0.0', help='Host (default: 0.0.0.0)')
parser.add_argument('--password', default=DEFAULT_PASSWORD, help=f'Password (default: {DEFAULT_PASSWORD})')
args = parser.parse_args()
# Change to script directory
os.chdir(SCRIPT_DIR)
# Create handler with password
Handler = create_auth_handler(args.password)
print("""
+==========================================================+
| Wyoming Satellite WebSocket Client Server |
+==========================================================+
| Serving: {:<53} |
| URL: http://localhost:{:<29} |
| Remote: http://<your-ip>:{:<25} |
| Password: {:<48} |
| |
| Press Ctrl+C to stop |
+==========================================================+
""".format(
str(SCRIPT_DIR),
args.port,
args.port,
'*' * len(args.password)
))
try:
with ReusableTCPServer((args.host, args.port), Handler) as httpd:
print(f"Server started on http://{args.host}:{args.port}")
print("Serving files from:", SCRIPT_DIR)
print(f"Password protected ({len(args.password)} characters)")
print("\nPress Ctrl+C to stop\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nShutting down gracefully...")
# Shutdown in a separate thread to avoid deadlock
shutdown_thread = threading.Thread(target=httpd.shutdown)
shutdown_thread.start()
shutdown_thread.join(timeout=2)
httpd.server_close()
print("Server stopped successfully")
except OSError as e:
print(f"\nError: Could not bind to port {args.port}")
print(f" {e}")
print("\nTry using a different port:")
print(f" python3 {sys.argv[0]} --port 8081\n")
sys.exit(1)
except Exception as e:
print(f"\nUnexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
-36
View File
@@ -1,36 +0,0 @@
"""Voice satellite using the Wyoming protocol."""
import importlib.metadata
from pathlib import Path
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
VadStreamingSatellite,
WakeStreamingSatellite,
)
from .settings import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
VadSettings,
WakeSettings,
)
_DIR = Path(__file__).parent
__version__ = importlib.metadata.version("wyoming-satellite")
__all__ = [
"__version__",
"AlwaysStreamingSatellite",
"EventSettings",
"MicSettings",
"SatelliteBase",
"SatelliteSettings",
"SndSettings",
"VadSettings",
"VadStreamingSatellite",
"WakeStreamingSatellite",
"WakeSettings",
]
-544
View File
@@ -1,544 +0,0 @@
"""Main entry point for Wyoming satellite."""
import argparse
import asyncio
import logging
import sys
from functools import partial
from pathlib import Path
from wyoming.info import Attribution, Info, Satellite
from wyoming.server import AsyncServer, AsyncTcpServer
from . import __version__
from .event_handler import SatelliteEventHandler
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
VadStreamingSatellite,
WakeStreamingSatellite,
)
from .settings import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
TimerSettings,
VadSettings,
WakeSettings,
WakeWordAndPipeline,
)
from .utils import (
get_mac_address,
needs_silero,
needs_webrtc,
run_event_command,
split_command,
)
_LOGGER = logging.getLogger()
_DIR = Path(__file__).parent
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
# Microphone input
parser.add_argument("--mic-uri", help="URI of Wyoming microphone service")
parser.add_argument("--mic-command", help="Program to run for microphone input")
parser.add_argument(
"--mic-command-rate",
type=int,
default=16000,
help="Sample rate of mic-command (hertz, default: 16000)",
)
parser.add_argument(
"--mic-command-width",
type=int,
default=2,
help="Sample width of mic-command (bytes, default: 2)",
)
parser.add_argument(
"--mic-command-channels",
type=int,
default=1,
help="Sample channels of mic-command (default: 1)",
)
parser.add_argument(
"--mic-command-samples-per-chunk",
type=int,
default=1024,
help="Sample per chunk for mic-command (default: 1024)",
)
parser.add_argument("--mic-volume-multiplier", type=float, default=1.0)
parser.add_argument(
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
)
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
parser.add_argument(
"--mic-seconds-to-mute-after-awake-wav",
type=float,
default=0.5,
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
)
parser.add_argument(
"--mic-no-mute-during-awake-wav",
action="store_true",
help="Don't mute the microphone while awake wav is being played",
)
parser.add_argument(
"--mic-channel-index",
type=int,
help="Take microphone input from a specific channel (first channel is 0)",
)
# Sound output
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
parser.add_argument("--snd-command", help="Program to run for sound output")
parser.add_argument(
"--snd-command-rate",
type=int,
default=22050,
help="Sample rate of snd-command (hertz, default: 22050)",
)
parser.add_argument(
"--snd-command-width",
type=int,
default=2,
help="Sample width of snd-command (bytes, default: 2)",
)
parser.add_argument(
"--snd-command-channels",
type=int,
default=1,
help="Sample channels of snd-command (default: 1)",
)
parser.add_argument("--snd-volume-multiplier", type=float, default=1.0)
# Local wake word detection
parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service")
parser.add_argument(
"--wake-word-name",
action="append",
default=[],
nargs="+",
metavar=("name", "pipeline"),
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
)
parser.add_argument("--wake-command", help="Program to run for wake word detection")
parser.add_argument(
"--wake-command-rate",
type=int,
default=16000,
help="Sample rate of wake-command (hertz, default: 16000)",
)
parser.add_argument(
"--wake-command-width",
type=int,
default=2,
help="Sample width of wake-command (bytes, default: 2)",
)
parser.add_argument(
"--wake-command-channels",
type=int,
default=1,
help="Sample channels of wake-command (default: 1)",
)
parser.add_argument(
"--wake-refractory-seconds",
type=float,
default=5.0,
help="Seconds after a wake word detection before another detection is handled (default: 5)",
)
# Voice activity detector
parser.add_argument(
"--vad", action="store_true", help="Wait for speech before streaming audio"
)
parser.add_argument(
"--vad-system",
dest="vad_system",
choices=("silero", "webrtc"),
default="silero",
help="VAD implementation to use (default: silero)",
)
parser.add_argument("--vad-threshold", type=float, default=0.5)
parser.add_argument("--vad-trigger-level", type=int, default=1)
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
parser.add_argument(
"--vad-wake-word-timeout",
type=float,
default=5.0,
help="Seconds before going back to waiting for speech when wake word isn't detected",
)
parser.add_argument(
"--vad-energy-threshold",
type=float,
help="Optional RMS energy threshold for 'webrtc' VAD (int16 scale)",
)
# External event handlers
parser.add_argument(
"--event-uri", help="URI of Wyoming service to forward events to"
)
parser.add_argument(
"--startup-command", help="Command run when the satellite starts"
)
parser.add_argument(
"--detect-command", help="Command to run when wake word detection starts"
)
parser.add_argument(
"--detection-command", help="Command to run when wake word is detected"
)
parser.add_argument(
"--transcript-command",
help="Command to run when speech to text transcript is returned",
)
parser.add_argument(
"--stt-start-command",
help="Command to run when the user starts speaking",
)
parser.add_argument(
"--stt-stop-command",
help="Command to run when the user stops speaking",
)
parser.add_argument(
"--synthesize-command",
help="Command to run when text to speech text is returned",
)
parser.add_argument(
"--tts-start-command",
help="Command to run when text to speech response starts",
)
parser.add_argument(
"--tts-stop-command",
help="Command to run when text to speech response stops",
)
parser.add_argument(
"--tts-played-command",
help="Command to run when text-to-speech audio stopped playing",
)
parser.add_argument(
"--streaming-start-command",
help="Command to run when audio streaming starts",
)
parser.add_argument(
"--streaming-stop-command",
help="Command to run when audio streaming stops",
)
parser.add_argument(
"--error-command",
help="Command to run when an error occurs",
)
parser.add_argument(
"--connected-command",
help="Command to run when connected to the server",
)
parser.add_argument(
"--disconnected-command",
help="Command to run when disconnected from the server",
)
parser.add_argument(
"--timer-started-command",
help="Command to run when a timer starts",
)
parser.add_argument(
"--timer-updated-command",
help="Command to run when a timer is paused, resumed, or has time added or removed",
)
parser.add_argument(
"--timer-cancelled-command",
"--timer-canceled-command",
help="Command to run when a timer is cancelled",
)
parser.add_argument(
"--timer-finished-command",
help="Command to run when a timer finishes",
)
# WebSocket server
parser.add_argument(
"--websocket-enabled",
action="store_true",
help="Enable WebSocket server for external clients (default: disabled)"
)
parser.add_argument(
"--websocket-host",
default="0.0.0.0",
help="Host for WebSocket server (default: 0.0.0.0)"
)
parser.add_argument(
"--websocket-port",
type=int,
default=8675,
help="Port for WebSocket server (default: 8675)"
)
# Audio level monitoring
parser.add_argument(
"--audio-level-enabled",
action="store_true",
help="Enable audio level monitoring and send audio-level events to WebSocket"
)
parser.add_argument(
"--audio-level-interval-ms",
type=int,
default=100,
help="Interval for audio-level events in milliseconds (default: 100)"
)
# Sounds
parser.add_argument(
"--awake-wav", help="WAV file to play when wake word is detected"
)
parser.add_argument(
"--done-wav", help="WAV file to play when voice command is done"
)
parser.add_argument(
"--timer-finished-wav", help="WAV file to play when a timer finishes"
)
parser.add_argument(
"--timer-finished-wav-repeat",
nargs=2,
metavar=("repeat", "delay"),
type=float,
default=(1, 0),
help="Number of times to play timer finished WAV and delay between repeats in seconds",
)
# Satellite details
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument(
"--name", default="Wyoming Satellite", help="Name of the satellite"
)
parser.add_argument("--area", help="Area name of the satellite")
# Zeroconf
parser.add_argument(
"--no-zeroconf", action="store_true", help="Disable discovery over zeroconf"
)
parser.add_argument(
"--zeroconf-name",
help="Name used for zeroconf discovery (default: MAC from uuid.getnode)",
)
parser.add_argument(
"--zeroconf-host",
help="Host address for zeroconf discovery (default: detect)",
)
#
parser.add_argument(
"--debug-recording-dir", help="Directory to store audio for debugging"
)
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
parser.add_argument(
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
)
parser.add_argument(
"--version",
action="version",
version=__version__,
help="Print version and exit",
)
args = parser.parse_args()
# Validate args
if (not args.mic_uri) and (not args.mic_command):
_LOGGER.fatal("Either --mic-uri or --mic-command is required")
sys.exit(1)
if needs_webrtc(args):
try:
import webrtc_noise_gain # noqa: F401
except ImportError:
_LOGGER.exception("Extras for webrtc are not installed")
sys.exit(1)
if needs_silero(args):
try:
import pysilero_vad # noqa: F401
except ImportError:
_LOGGER.exception("Extras for silerovad are not installed")
sys.exit(1)
if args.awake_wav and (not Path(args.awake_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.awake_wav)
sys.exit(1)
if args.done_wav and (not Path(args.done_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.done_wav)
sys.exit(1)
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
sys.exit(1)
if args.vad and (args.wake_uri or args.wake_command):
_LOGGER.warning("VAD is not used with local wake word detection")
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
)
_LOGGER.debug(args)
if args.debug_recording_dir:
args.debug_recording_dir = Path(args.debug_recording_dir)
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
wyoming_info = Info(
satellite=Satellite(
name=args.name,
area=args.area,
description=args.name,
attribution=Attribution(name="", url=""),
installed=True,
version=__version__,
)
)
settings = SatelliteSettings(
mic=MicSettings(
uri=args.mic_uri,
command=split_command(args.mic_command),
rate=args.mic_command_rate,
width=args.mic_command_width,
channels=args.mic_command_channels,
samples_per_chunk=args.mic_command_samples_per_chunk,
volume_multiplier=args.mic_volume_multiplier,
auto_gain=args.mic_auto_gain,
noise_suppression=args.mic_noise_suppression,
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
channel_index=args.mic_channel_index,
),
vad=VadSettings(
enabled=args.vad,
system=args.vad_system,
threshold=args.vad_threshold,
trigger_level=args.vad_trigger_level,
buffer_seconds=args.vad_buffer_seconds,
wake_word_timeout=args.vad_wake_word_timeout,
energy_threshold=args.vad_energy_threshold,
),
wake=WakeSettings(
uri=args.wake_uri,
command=split_command(args.wake_command),
names=[
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
],
refractory_seconds=(
args.wake_refractory_seconds
if args.wake_refractory_seconds > 0
else None
),
),
snd=SndSettings(
uri=args.snd_uri,
command=split_command(args.snd_command),
rate=args.snd_command_rate,
width=args.snd_command_width,
channels=args.snd_command_channels,
volume_multiplier=args.snd_volume_multiplier,
awake_wav=args.awake_wav,
done_wav=args.done_wav,
),
event=EventSettings(
uri=args.event_uri,
startup=split_command(args.startup_command),
streaming_start=split_command(args.streaming_start_command),
streaming_stop=split_command(args.streaming_stop_command),
detect=split_command(args.detect_command),
detection=split_command(args.detection_command),
played=split_command(args.tts_played_command),
transcript=split_command(args.transcript_command),
stt_start=split_command(args.stt_start_command),
stt_stop=split_command(args.stt_stop_command),
synthesize=split_command(args.synthesize_command),
tts_start=split_command(args.tts_start_command),
tts_stop=split_command(args.tts_stop_command),
error=split_command(args.error_command),
connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command),
# WebSocket server
websocket_enabled=args.websocket_enabled,
websocket_host=args.websocket_host,
websocket_port=args.websocket_port,
# Audio level
audio_level_enabled=args.audio_level_enabled,
audio_level_interval_ms=args.audio_level_interval_ms,
),
timer=TimerSettings(
started=split_command(args.timer_started_command),
updated=split_command(args.timer_updated_command),
cancelled=split_command(args.timer_cancelled_command),
finished=split_command(args.timer_finished_command),
finished_wav=args.timer_finished_wav,
finished_wav_plays=int(args.timer_finished_wav_repeat[0]),
finished_wav_delay=args.timer_finished_wav_repeat[1],
),
debug_recording_dir=args.debug_recording_dir,
)
satellite: SatelliteBase
if settings.wake.enabled:
# Local wake word detection
satellite = WakeStreamingSatellite(settings)
elif settings.vad.enabled:
# Stream after speech
satellite = VadStreamingSatellite(settings)
else:
# Stream all the time
satellite = AlwaysStreamingSatellite(settings)
if args.startup_command:
await run_event_command(split_command(args.startup_command))
_LOGGER.info("Ready")
# Start server
server = AsyncServer.from_uri(args.uri)
if (not args.no_zeroconf) and isinstance(server, AsyncTcpServer):
from wyoming.zeroconf import register_server
if not args.zeroconf_name:
args.zeroconf_name = get_mac_address()
tcp_server: AsyncTcpServer = server
await register_server(
name=args.zeroconf_name,
port=tcp_server.port,
host=args.zeroconf_host,
)
_LOGGER.debug(
"Zeroconf discovery enabled (name=%s, host=%s)",
args.zeroconf_name,
args.zeroconf_host,
)
satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
try:
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
except KeyboardInterrupt:
pass
finally:
await satellite.stop()
await satellite_task
# -----------------------------------------------------------------------------
def run():
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
run()

Some files were not shown because too many files have changed in this diff Show More