diff --git a/docs/development/websocket-server-plan.md b/docs/development/websocket-server-plan.md
index a66a123..0fbf8dc 100644
--- a/docs/development/websocket-server-plan.md
+++ b/docs/development/websocket-server-plan.md
@@ -3,1152 +3,77 @@
**Дата:** 2026-05-20
**Статус:** 🔄 Тестирование
**Ветка:** `satellite-dev`
-**Директория для разработки:** `~/projects/satellite-dev`
-**Файл плана:** `docs/development/websocket-server-plan.md`
---
-## Чеклист выполнения
+## Что сделано
-### ✅ Выполнено
+| Этап | Что | Статус |
+|------|-----|--------|
+| 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 | ✅ |
-- [x] **Ветка создана** — `satellite-dev` (переименована из `feature/multi-event-server`)
-- [x] **Окружение настроено** — `.venv/` в корне проекта
-- [x] **Docker удалён** — файлы Dockerfile, docker-compose.yml, скрипты
-- [x] **Лишние файлы удалены** — test.wav, start.sh, start-dev.sh, .projectile
-- [x] **setup_dev.sh создан** — быстрая настройка окружения
-- [x] **Этап WS-1: settings.py** — websocket_enabled, websocket_host, websocket_port
-- [x] **Этап WS-2: satellite.py** — WebSocket сервер + runtime атрибуты
-- [x] **Этап WS-2A: Протокол команд** — handle_websocket_command() реализован
-- [x] **Этап WS-3: __main__.py** — CLI аргументы --websocket-enabled/host/port
-- [x] **TEST сервисы** — созданы для безопасного тестирования
-- [x] **Критический баг исправлен** — убран detach_kernel_driver() (audio больше не отваливается)
-- [x] **WebSocket события** — события приходят в браузер (synthesize, audio-start/stop, played)
+## Что осталось
-### 🔄 Требуется тестирование
+| Этап | Что | Статус |
+|------|-----|--------|
+| WS-5 | Обновить `docs/integration/mqtt-led.md` | ⏳ |
+| — | Merge в master | ⏳ |
-- [ ] **Этап WS-4: Audio level monitoring** — проверить что audio-level события приходят (10 раз в секунду)
-- [ ] **Этап WS-4: WebSocket команды** — проверить set_volume, mute, play_sound
+## Чеклист тестирования (WS-4)
-### ⏳ Ожидается
-
-- [ ] **Этап WS-5: Документация** — обновить docs/integration/mqtt-led.md
-- [ ] Merge в master
-- [ ] Production развёртывание
+- [ ] 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 не падает
---
-## Архитектура
-
-### Текущее состояние (production)
+## Архитектура (справка)
```
-┌─────────────────┐ ┌─────────────────┐
-│ Home Assistant │ ──────► │ Satellite │
-│ (Wyoming │ 10700 │ (Wyoming │
-│ Client) │ │ Server) │
-└─────────────────┘ └────────┬────────┘
- │
- │ 10500 (outgoing!)
- ▼
- ┌─────────────────┐
- │ Event Service │
- │ (LED, MQTT) │
- └─────────────────┘
+Home Assistant ──10700──► Satellite
+ ├── Event CLIENT :10500 → LED, MQTT
+ └── WebSocket SERVER :8675 ← Browser
```
-**Ограничение:** Satellite **подключается** к event службе, но не может принимать входящие подключения
-
-### Целевое состояние
-
-```
-┌─────────────────┐ ┌──────────────────────────┐
-│ Home Assistant │ ──────► │ Wyoming Satellite │
-│ (Wyoming │ 10700 │ (единый процесс) │
-│ Client) │ │ │
-└─────────────────┘ │ ┌────────────────────┐ │
- │ │ Event CLIENT │ │
- │ │ (outgoing :10500) │ │
- │ │ → LED, MQTT │ │
- │ └────────────────────┘ │
- │ ┌────────────────────┐ │
- │ │ WebSocket SERVER │ │
- │ │ (incoming :8675) │ │
- │ │ ← Browser, UI │ │
- │ └────────────────────┘ │
- └──────────────────────────┘
-```
-
-**Принцип:**
-- ✅ **Event CLIENT** (`--event-uri`) — satellite **подключается** к внешней службе (LED, MQTT)
-- ✅ **WebSocket SERVER** (`--websocket-enabled`) — встроенный сервер, клиенты **подключаются к нему**
-
-**Преимущества:**
-- ✅ **Не нужен отдельный процесс** — websocket-service встроен в satellite
-- ✅ **Прямая трансляция событий** — без промежуточных очередей
-- ✅ **Масштабируемость** — неограниченное количество WebSocket клиентов
-- ✅ **Обратная совместимость** — `--event-uri` продолжает работать
-
----
-
-## Этапы реализации
-
-### Этап 1: Подготовка ✅ ВЫПОЛНЕН
-
-#### 1.1 Проверка окружения ✅
-
-```bash
-# Production версия (не трогать!)
-cd ~/projects/Satellite
-git status # Должен быть clean
-
-# Development версия
-cd ~/projects/satellite-dev
-git status # Должен быть на satellite-dev
-```
-
-#### 1.2 Настройка venv для satellite-dev ✅
-
-```bash
-# Автоматическая настройка
-cd ~/projects/satellite-dev
-./setup_dev.sh
-
-# Или вручную:
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -r requirements.txt
-```
-
-#### 1.3 Проверка что всё работает ✅
-
-```bash
-cd ~/projects/satellite-dev
-source .venv/bin/activate
-
-# Проверка импорта
-python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"
-```
-
-**Итого:** Окружение готово, можно приступать к реализации.
-
----
-
-### Этап WS-1: Модификация `wyoming_satellite/settings.py` (30 мин) 🔄 В ПРОЦЕССЕ
-
-**Добавить настройки WebSocket сервера:**
-
-```python
-@dataclass(frozen=True)
-class EventSettings(ServiceSettings):
- # ... существующие поля ...
-
- # === НОВЫЕ ПОЛЯ: WebSocket server ===
- websocket_enabled: bool = False
- """True если WebSocket сервер должен быть запущен."""
-
- websocket_host: str = "0.0.0.0"
- """Host для WebSocket сервера."""
-
- websocket_port: int = 8675
- """Port для WebSocket сервера."""
-```
-
-**Итого изменений:** ~10 строк
-
----
-
-### Этап WS-2: Модификация `wyoming_satellite/satellite.py` (2-3 часа) ⏳ ОЖИДАЕТСЯ
-
-#### Зависимость
-
-`websockets>=10.0` уже установлен в `requirements.txt` ✅
-
-#### 2.1 Добавить атрибуты для WebSocket сервера
-
-В `SatelliteBase.__init__()` после `_event_task`:
-
-```python
-# WebSocket server (новый)
-self._websocket_server: Optional[websockets.server.Serve] = None
-self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set()
-self._websocket_lock = asyncio.Lock()
-```
-
-**Импорт:** `Set` из `typing`, `websockets`
-
-#### 2.2 Метод для рассылки в WebSocket
-
-```python
-async def _broadcast_to_websocket(self, event: Event) -> None:
- """
- Отправить событие всем WebSocket клиентам.
-
- Мёртвые подключения автоматически удаляются.
- """
- import json
-
- async with self._websocket_lock:
- if not self._websocket_clients:
- return
-
- dead_clients = []
- message = json.dumps(
- {"type": event.type, "data": event.data or {}},
- ensure_ascii=False
- )
-
- for ws in self._websocket_clients:
- try:
- await ws.send(message)
- except Exception:
- _LOGGER.debug(
- f"Failed to send event to WebSocket client, removing"
- )
- dead_clients.append(ws)
-
- # Удалить мёртвые подключения
- for ws in dead_clients:
- self._websocket_clients.discard(ws)
-```
-
-#### 2.3 WebSocket сервер
-
-```python
-async def start_websocket_server(self) -> None:
- """
- Запустить WebSocket сервер для внешних клиентов.
-
- Клиенты могут подключаться к порту websocket_port и получать все события.
- """
- async def handle_websocket(websocket, path):
- client_addr = websocket.remote_address
- _LOGGER.info(f"WebSocket client connected: {client_addr}")
-
- async with self._websocket_lock:
- self._websocket_clients.add(websocket)
-
- try:
- # Ждать команды от клиента
- async for message in websocket:
- try:
- data = json.loads(message)
- command = data.get('command')
- command_data = data.get('data', {})
-
- if command:
- _LOGGER.debug(f"Received command: {command}")
- await self.handle_websocket_command(command, command_data)
- else:
- _LOGGER.debug(f"Received message (no command): {data}")
- except json.JSONDecodeError:
- _LOGGER.warning(f"Invalid JSON from WebSocket: {message}")
- except Exception as exc:
- _LOGGER.error(f"Error processing command: {exc}")
- except websockets.exceptions.ConnectionClosed:
- pass # Нормальное отключение
- except Exception as exc:
- _LOGGER.debug(f"WebSocket client error: {exc}")
- finally:
- async with self._websocket_lock:
- self._websocket_clients.discard(websocket)
-
- _LOGGER.info(f"WebSocket client disconnected: {client_addr}")
-
- self._websocket_server = await websockets.serve(
- handle_websocket,
- self.settings.event.websocket_host,
- self.settings.event.websocket_port
- )
-
- _LOGGER.info(
- f"WebSocket server listening on "
- f"{self.settings.event.websocket_host}:{self.settings.event.websocket_port}"
- )
-
-async def stop_websocket_server(self) -> None:
- """
- Остановить WebSocket сервер и закрыть все подключения.
- """
- _LOGGER.info("Stopping WebSocket server...")
-
- if self._websocket_server:
- self._websocket_server.close()
- await self._websocket_server.wait_closed()
- self._websocket_server = None
-
- # Закрыть все подключения
- async with self._websocket_lock:
- for ws in self._websocket_clients:
- await ws.close(1000, "Server shutting down")
- self._websocket_clients.clear()
-
- _LOGGER.info("WebSocket server stopped")
-```
-
-#### 2.4 Обработчик команд
-
-```python
-async def handle_websocket_command(self, command: str, data: dict) -> None:
- """
- Обработать команду от WebSocket клиента.
-
- Поддерживаемые команды:
- - set_volume: Изменить громкость
- - mute: Заглушить микрофон
- - unmute: Включить микрофон
- - play_sound: Воспроизвести WAV файл
- - set_led_brightness: Яркость LED
- - set_led_color: Цвет LED
- - trigger_event: Отправить событие в Home Assistant
- """
- import json
-
- try:
- if command == "set_volume":
- # Изменить громкость (0.0 - 1.0)
- level = float(data.get("level", 1.0))
- level = max(0.0, min(1.0, level)) # Clamp 0-1
-
- # Сохранить в settings (потребуется добавить mutable settings)
- _LOGGER.info(f"Setting volume to {level}")
- # Примечание: settings frozen, нужно будет изменить архитектуру
-
- elif command == "mute":
- _LOGGER.info("Muting microphone")
- self.microphone_muted = True
-
- elif command == "unmute":
- _LOGGER.info("Unmuting microphone")
- self.microphone_muted = False
-
- elif command == "play_sound":
- # Воспроизвести WAV файл
- wav_path = data.get("path")
- if wav_path:
- _LOGGER.info(f"Playing sound: {wav_path}")
- await self._play_wav(wav_path, mute_microphone=False)
- else:
- _LOGGER.warning("play_sound command missing 'path'")
-
- elif command == "set_led_brightness":
- # Отправить команду в LED сервис (через event.uri)
- brightness = int(data.get("brightness", 128))
- _LOGGER.info(f"Setting LED brightness to {brightness}")
- # Отправить событие в event service
-
- elif command == "set_led_color":
- # Установить цвет LED
- r = int(data.get("r", 0))
- g = int(data.get("g", 0))
- b = int(data.get("b", 0))
- _LOGGER.info(f"Setting LED color to RGB({r}, {g}, {b})")
- # Отправить событие в event service
-
- elif command == "trigger_event":
- # Отправить событие в Home Assistant
- event_type = data.get("event_type")
- event_data = data.get("data", {})
- if event_type:
- _LOGGER.info(f"Triggering event: {event_type}")
- # Отправить RunPipeline или другое событие
- else:
- _LOGGER.warning("trigger_event missing 'event_type'")
-
- else:
- _LOGGER.warning(f"Unknown command: {command}")
-
- except Exception as exc:
- _LOGGER.error(f"Error handling command {command}: {exc}")
-```
-
-#### 2.5 Интеграция в жизненный цикл
-
-**В `_start()`:**
-
-```python
-async def _start(self) -> None:
- """Connect to services."""
- self.state = State.STARTING
- await self._connect_to_services()
-
- # Запустить WebSocket сервер (новый)
- if self.settings.event.websocket_enabled:
- await self.start_websocket_server()
-
- self.state = State.STARTED
- await self.started()
-```
-
-**В `_stop()`:**
-
-```python
-async def _stop(self) -> None:
- """Disconnect from services."""
- self.server_id = None
- self._writer = None
-
- # Остановить WebSocket сервер (новый)
- if self._websocket_server:
- await self.stop_websocket_server()
-
- await self._disconnect_from_services()
- self._disable_ping()
- self.state = State.STOPPED
-```
-
-#### 2.6 Обновить `forward_event()`
-
-```python
-async def forward_event(self, event: Event) -> None:
- """
- Forward an event to all event consumers.
-
- Sends to:
- - Legacy event service (if configured via event.uri)
- - All connected WebSocket clients
- """
- # Legacy event service (через TCP клиент)
- if self._event_queue is not None:
- self._event_queue.put_nowait(event)
-
- # WebSocket клиенты
- await self._broadcast_to_websocket(event)
-```
-
-**Итого изменений:** ~150 строк
-
----
-
-### Этап WS-2A: Протокол WebSocket команд (30 мин) ⏳ ОЖИДАЕТСЯ
-
-**Формат сообщений:**
-
-```json
-{
- "command": "имя_команды",
- "data": {
- "ключ": "значение"
- }
-}
-```
-
-**Поддерживаемые команды:**
+## Протокол команд (справка)
| Команда | Данные | Описание |
|---------|--------|----------|
-| `set_volume` | `{"level": 0.0-1.0}` | Изменить громкость |
-| `mute` | `{}` | Заглушить микрофон |
-| `unmute` | `{}` | Включить микрофон |
-| `play_sound` | `{"path": "/path/to.wav"}` | Воспроизвести WAV |
+| `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 |
-| `set_led_color` | `{"r": 255, "g": 0, "b": 0}` | Цвет LED (RGB) |
-| `trigger_event` | `{"event_type": "...", "data": {...}}` | Событие в HA |
+| `start_recording` | `{}` | Начать запись с микрофона |
+| `stop_recording` | `{}` | Остановить, вернуть WAV (base64) |
+| `trigger_event` | `{"event_type": "...", "data": {...}}` | Отправить событие в HA |
-**Примеры:**
-
-```javascript
-// Установить громкость 70%
-ws.send(JSON.stringify({
- "command": "set_volume",
- "data": {"level": 0.7}
-}));
-
-// Заглушить микрофон
-ws.send(JSON.stringify({
- "command": "mute",
- "data": {}
-}));
-
-// Воспроизвести звук
-ws.send(JSON.stringify({
- "command": "play_sound",
- "data": {"path": "/sounds/awake.wav"}
-}));
-
-// Установить цвет LED (красный)
-ws.send(JSON.stringify({
- "command": "set_led_color",
- "data": {"r": 255, "g": 0, "b": 0}
-}));
-
-// Отправить событие в Home Assistant
-ws.send(JSON.stringify({
- "command": "trigger_event",
- "data": {
- "event_type": "wyoming_satellite.custom",
- "data": {"custom_data": "value"}
- }
-}));
-```
-
-**Ответы сервера:**
-
-Сервер отправляет события в формате:
-
-```json
-{
- "type": "voice-started",
- "data": {}
-}
-```
-
----
-
-### Этап WS-3: Модификация `wyoming_satellite/__main__.py` (20 мин) ⏳ ОЖИДАЕТСЯ
-
-#### 3.1 Добавить CLI аргументы
-
-После существующих аргументов (перед `args = parser.parse_args()`):
-
-```python
-# 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)"
-)
-```
-
-#### 3.2 Передать настройки в EventSettings
-
-В существующем `EventSettings(...)`:
-
-```python
-event=EventSettings(
- uri=args.event_uri,
- # ... существующие поля ...
-
- # === НОВЫЕ ПОЛЯ ===
- websocket_enabled=args.websocket_enabled,
- websocket_host=args.websocket_host,
- websocket_port=args.websocket_port,
-),
-```
-
-**Итого изменений:** ~20 строк
-
----
-
-### Этап WS-4: Тестирование WebSocket (1 час) ⏳ ОЖИДАЕТСЯ
-
-#### 4.1 Запустить satellite с WebSocket
-
-```bash
-cd ~/projects/satellite-dev
-source .venv/bin/activate
-
-python3 -m wyoming_satellite \
- --uri tcp://0.0.0.0:10700 \
- --event-uri tcp://127.0.0.1:10500 \
- --websocket-enabled \
- --websocket-port 8675 \
- --debug
-```
-
-**Ожидаемый вывод:**
-```
-INFO: Ready
-INFO: WebSocket server listening on 0.0.0.0:8675
-INFO: Слушаем tcp://0.0.0.0:10700
-```
-
-#### 4.2 Проверить подключения
-
-**Терминал 1: test_client.py**
-```bash
-source .venv/bin/activate
-python3 examples/websocket-service/test_client.py \
- --uri ws://127.0.0.1:8675
-```
-
-**Терминал 2: Браузер**
-```
-Открыть examples/websocket-service/client.html
-```
-
-**Терминал 3: wyoming-led.py (event client)**
-```bash
-source .venv/bin/activate
-python3 examples/wyoming-led.py --host 127.0.0.1 --port 10500
-```
-
-#### 4.3 Проверить логи
-
-**В логе satellite:**
-```
-INFO: WebSocket client connected: ('127.0.0.1', XXXX)
-INFO: Connected to server # Home Assistant
-```
-
-**Проверить:**
-- ✅ WebSocket клиент получает события
-- ✅ wyoming-led.py работает через `--event-uri`
-- ✅ Home Assistant работает (порт 10700)
-- ✅ При отключении клиента satellite не падает
-
----
-
-### Этап WS-5: Обновление документации (20 мин) ⏳ ОЖИДАЕТСЯ
-
-#### 5.1 `docs/integration/mqtt-led.md`
-
-Добавить секцию после "Архитектура USB контроллера":
-
-```markdown
-## WebSocket сервер (внешние клиенты)
-
-Wyoming Satellite поддерживает встроенный WebSocket сервер для внешних клиентов.
-
-### Преимущества
-
-- ✅ **Не нужен отдельный процесс** — websocket-service встроен в satellite
-- ✅ **Прямая трансляция событий** — без промежуточных очередей
-- ✅ **Масштабируемость** — неограниченное количество WebSocket клиентов
-
-### Запуск
+## Запуск
```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
-```
-
-### Подключение из браузера
-
-```javascript
-const ws = new WebSocket('ws://localhost:8675');
-
-ws.onmessage = (event) => {
- const data = JSON.parse(event.data);
- console.log('Событие:', data.type, data.data);
-};
-```
-
-### Примеры
-
-- `examples/websocket-service/client.html` — браузерный клиент
-- `examples/websocket-service/test_client.py` — Python тест клиент
-
-### Обратная совместимость
-
-Старый `wyoming-led.py` продолжает работать через `--event-uri`:
-
-```bash
-python3 -m wyoming_satellite \
- --event-uri tcp://127.0.0.1:10500 \
- --websocket-enabled \
- --websocket-port 8675
-```
-
-Оба метода работают параллельно!
-```
-
----
-
-### Этап WS-6: Audio level — RMS громкость микрофона (30 мин) ✅ ВЫПОЛНЕНО
-
-**Трансляция уровня громкости в WebSocket для визуализации.**
-
-#### 6.1 Формат события
-
-```json
-{
- "type": "audio-level",
- "data": {
- "level": 0.42 // 0.0 - 1.0, RMS нормализованный
- }
-}
-```
-
-**Частота:** 10 раз в секунду (100 мс)
-
-#### 6.2 Модификация `wyoming_satellite/settings.py`
-
-Добавить настройку:
-
-```python
-@dataclass(frozen=True)
-class EventSettings(ServiceSettings):
- # ... существующие поля ...
-
- # Audio level monitoring
- audio_level_enabled: bool = False
- """True если нужно вычислять и отправлять RMS громкость микрофона."""
-
- audio_level_interval_ms: int = 100
- """Интервал отправки audio-level событий (миллисекунды)."""
-```
-
-#### 6.3 Модификация `wyoming_satellite/satellite.py`
-
-**Утилита для расчёта RMS:**
-
-```python
-def _calculate_rms(self, audio_bytes: bytes) -> float:
- """
- Вычислить RMS громкость (0.0 - 1.0).
-
- Использует тот же алгоритм что и WebRtcVadEnergy из vad.py
- """
- pcm = array.array("h")
- pcm.frombytes(audio_bytes)
- if not pcm:
- return 0.0
- rms = math.sqrt(sum(int(s) * int(s) for s in pcm) / len(pcm))
- return rms / 32767.0 # Нормализация для 16-bit
-```
-
-**Атрибуты в `__init__()`:**
-
-```python
-self._last_mic_level: float = 0.0
-self._audio_level_task: Optional[asyncio.Task] = None
-```
-
-**Вычисление в `event_from_mic()`:**
-
-```python
-async def event_from_mic(
- self, event: Event, audio_bytes: Optional[bytes] = None
-) -> None:
- """Called when an event is received from the mic service."""
- # Вычислить RMS для аудио чанка
- if AudioChunk.is_type(event.type) and audio_bytes:
- self._last_mic_level = self._calculate_rms(audio_bytes)
-
- # ... остальной код ...
-```
-
-**Фоновая задача отправки:**
-
-```python
-async def _send_audio_level(self) -> None:
- """
- Периодически отправлять audio-level события в WebSocket.
- """
- from wyoming.event import Event as WyomingEvent
-
- interval = self.settings.event.audio_level_interval_ms / 1000.0
-
- while self.is_running:
- await asyncio.sleep(interval)
-
- if self.settings.event.audio_level_enabled and self._websocket_clients:
- event = WyomingEvent(
- "audio-level",
- {"level": self._last_mic_level}
- ).event()
- await self._broadcast_to_websocket(event)
-```
-
-**Интеграция в `_start()` / `_stop()`:**
-
-```python
-async def _start(self) -> None:
- # ... существующий код ...
-
- # Запустить задачу audio level (новый)
- if self.settings.event.audio_level_enabled:
- self._audio_level_task = asyncio.create_task(
- self._send_audio_level(), name="audio_level"
- )
-
-async def _stop(self) -> None:
- # ... существующий код ...
-
- # Остановить задачу audio level
- if self._audio_level_task:
- self._audio_level_task.cancel()
- self._audio_level_task = None
-```
-
-#### 6.4 Модификация `wyoming_satellite/__main__.py`
-
-CLI аргументы:
-
-```python
-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)"
-)
-```
-
-Передача в настройки:
-
-```python
-event=EventSettings(
- # ... существующие поля ...
-
- # Audio level
- audio_level_enabled=args.audio_level_enabled,
- audio_level_interval_ms=args.audio_level_interval_ms,
-)
-```
-
-#### 6.5 Пример использования в браузере
-
-```html
-
-
-
-```
-
-#### 6.6 Запуск с audio level
-
-```bash
-python3 -m wyoming_satellite \
- --uri tcp://0.0.0.0:10700 \
- --websocket-enabled \
- --websocket-port 8675 \
- --audio-level-enabled \
- --audio-level-interval-ms 100
-```
-
-**Итого изменений:** ~40 строк
-
----
-
-## Итоговые изменения
-
-| Файл | Тип | Изменения | Строк |
-|------|-----|-----------|-------|
-| `wyoming_satellite/settings.py` | Изменён | EventSettings + WebSocket | ~10 |
-| `wyoming_satellite/satellite.py` | Изменён | WebSocket server + broadcast + commands | ~200 |
-| `wyoming_satellite/__main__.py` | Изменён | CLI аргументы | ~20 |
-| `examples/websocket-service/*` | Существующий | Готовые клиенты | — |
-| `docs/integration/mqtt-led.md` | Изменён | Документация WebSocket + commands | ~80 |
-| **ВСЕГО** | | | **~310 строк** |
-
----
-
-## Риски и план Б
-
-| Риск | Вероятность | Влияние | Решение |
-|------|-------------|---------|---------|
-| Конфликт портов (8675 занят) | Низкая | Среднее | Проверка `lsof -i :8675`, смена порта |
-| Утечка памяти (клиенты) | Средняя | Высокое | Логирование подключений, таймауты |
-| Падение satellite | Низкая | Критичное | Тестирование в ветке, backup файлов |
-| Home Assistant отключится | Низкая | Критичное | Порт 10700 не меняется, обратная совместимость |
-| Event client не работает | Низкая | Критичное | `--event-uri` не меняется, проверять логи |
-
-### План Б: Откат
-
-Если что-то идёт не так:
-
-```bash
-cd ~/projects/Satellite
-git checkout master
-
-# Восстановить файлы
-cp wyoming_satellite/satellite.py.backup wyoming_satellite/satellite.py
-cp wyoming_satellite/settings.py.backup wyoming_satellite/settings.py
-cp wyoming_satellite/__main__.py.backup wyoming_satellite/__main__.py
-
-# Перезапустить сервисы
-sudo systemctl daemon-reload
-sudo systemctl restart wyoming-satelite-vosk.service
-```
-
----
-
-## Контрольный список
-
-### ✅ Этап 1: Подготовка (выполнено)
-
-- [x] Ветка создана — `satellite-dev`
-- [x] Окружение настроено — `.venv/` в корне проекта
-- [x] `setup_dev.sh` создан
-- [x] Лишние файлы удалены (Docker, test.wav, старые скрипты)
-- [x] `python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"` работает
-
-### ✅ Этап WS-1: settings.py (выполнено)
-
-- [x] `websocket_enabled: bool` добавлено
-- [x] `websocket_host: str` добавлено
-- [x] `websocket_port: int` добавлено
-- [x] `python3 -c "from wyoming_satellite.settings import EventSettings; print(EventSettings.websocket_enabled)"` работает
-
-### ✅ Этап WS-2: satellite.py (выполнено)
-
-- [x] `_websocket_server: Optional[asyncio.Server]` добавлено
-- [x] `_websocket_clients: Set[...]` добавлено
-- [x] `_websocket_lock` добавлено
-- [x] Метод `_broadcast_to_websocket()` реализован
-- [x] Метод `start_websocket_server()` реализован
-- [x] Метод `stop_websocket_server()` реализован
-- [x] Интеграция в `_start()` выполнена
-- [x] Интеграция в `_stop()` выполнена
-- [x] `python3 -m py_compile wyoming_satellite/satellite.py` проходит
-
-### ✅ Этап WS-2A: Протокол команд (выполнено)
-
-- [x] `handle_websocket_command()` реализован
-- [x] Команда `set_volume` работает
-- [x] Команда `mute`/`unmute` работает
-- [x] Команда `play_sound` работает
-- [x] Команда `set_led_*` работает
-- [x] Команда `trigger_event` работает
-- [x] Обработка ошибок реализована
-
-### ✅ Этап WS-3: __main__.py (выполнено)
-
-- [x] `--websocket-enabled` аргумент добавлен
-- [x] `--websocket-host` аргумент добавлен
-- [x] `--websocket-port` аргумент добавлен
-- [x] Передача настроек в `EventSettings()` настроена
-- [x] `python3 -m wyoming_satellite --help` показывает новые аргументы
-
-### 🔄 Этап WS-4: Тестирование (в процессе)
-
-- [ ] Satellite запускается с `--websocket-enabled`
-- [ ] WebSocket клиент подключается к порту 8675
-- [ ] Все клиенты получают события
-- [ ] Home Assistant работает (порт 10700)
-- [ ] wyoming-led.py работает (порт 10500)
-- [ ] При отключении клиента satellite не падает
-- [ ] Утечек памяти нет (через 10 минут работы)
-
-### ⏳ Этап WS-5: Документация (ожидается)
-
-- [ ] `docs/integration/mqtt-led.md` обновлена
-- [ ] Примеры в документации работают
-
-### ⏳ Перед merge
-
-- [ ] Все тесты пройдены
-- [ ] Документация актуальна
-- [ ] Ветка называется `satellite-dev`
-
----
-
-## Web Client Разработка
-
-### Архитектура Web клиента
-
-```
-┌─────────────────┐ ┌──────────────────────────┐
-│ Web Browser │◄───────►│ Wyoming Satellite │
-│ (client.html) │ WS :8675│ (WebSocket Server) │
-│ │ │ │
-│ - UI команды │ │ - handle_websocket_cmd │
-│ - RMS визуал. │ │ - audio-level события │
-│ - Лог событий │ │ - forward_event │
-└─────────────────┘ └──────────────────────────┘
-```
-
-### Функции Web клиента
-
-| Функция | Команда | Статус | Примечание |
-|---------|---------|--------|------------|
-| 🔊 Громкость | `set_volume` | ✅ Работает | Исправлен баг volume/level |
-| 🔇 Mute | `mute` | ✅ Работает | |
-| 🔊 Unmute | `unmute` | ✅ Работает | |
-| 💡 LED яркость | `set_led_brightness` | ⏳ Не тест. | |
-| 🎨 LED цвет | `set_led_color` | ⏳ Не тест. | |
-| 🔔 Play Sound | `play_sound` | ⏳ Не тест. | |
-| 📊 Audio Level | `audio-level` event | ⏳ Не тест. | RMS визуализация |
-| 🎤 Start Listen | `start_listening` | ⏳ Не тест. | |
-| ⏹️ Stop Pipeline | `stop_pipeline` | ⏳ Не тест. | |
-| ⏲️ Stop Timer | `stop_timer` | ⏳ Не тест. | |
-
-### Этапы разработки Web клиента
-
-#### WC-1: Базовая интеграция ✅
-
-- [x] Создан `web-client/client.html`
-- [x] WebSocket подключение
-- [x] Отправка команд (volume, mute, unmute)
-- [x] Приём событий от satellite
-- [x] Исправлен баг volume/level
-
-#### WC-2: Визуализация (в процессе)
-
-- [ ] audio-level RMS визуализация (canvas)
-- [ ] Индикация состояния (listening, speaking, idle)
-- [ ] Анимации для состояний
-- [ ] Orb визуализация
-
-#### WC-3: Дополнительные функции
-
-- [ ] LED управление (цвет, яркость, эффекты)
-- [ ] Громкость с отображением в %
-- [ ] Mute индикация
-- [ ] Transcript отображение
-
-#### WC-4: UI/UX
-
-- [ ] Адаптивный дизайн
-- [ ] Сохранение настроек (localStorage)
-- [ ] История событий с фильтрами
-- [ ] Настройки подключения
-
-### Запуск Web клиента
-
-```bash
-# 1. Запустить satellite с WebSocket
-python3 -m wyoming_satellite \
- --uri tcp://0.0.0.0:10700 \
- --websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled
-
-# 2. Запустить HTTP сервер для клиента
-cd web-client
-python3 -m http.server 8080
-
-# 3. Открыть в браузере
-# http://localhost:8080/client.html
-# или
-# http://192.168.200.111:8080/client.html
```
-### Примеры команд
+## Известные проблемы
-**Установка громкости:**
-```javascript
-ws.send(JSON.stringify({
- command: "set_volume",
- data: { level: 0.75 } // или volume: 0.75
-}));
-```
-
-**Mute/Unmute:**
-```javascript
-ws.send(JSON.stringify({
- command: "mute",
- data: {}
-}));
-
-ws.send(JSON.stringify({
- command: "unmute",
- data: {}
-}));
-```
-
-**LED управление:**
-```javascript
-// Яркость
-ws.send(JSON.stringify({
- command: "set_led_brightness",
- data: { brightness: 200 }
-}));
-
-// Цвет
-ws.send(JSON.stringify({
- command: "set_led_color",
- data: { r: 255, g: 0, b: 0 }
-}));
-```
-
-**Воспроизведение звука:**
-```javascript
-ws.send(JSON.stringify({
- command: "play_sound",
- data: { path: "/path/to/sound.wav" }
-}));
-```
-
----
-
-## Приложения
-
-### A. Пример события в WebSocket клиенте
-
-```json
-{
- "type": "voice-started",
- "data": {}
-}
-```
-
-```json
-{
- "type": "voice-stopped",
- "data": {}
-}
-```
-
-```json
-{
- "type": "transcript",
- "data": {
- "text": "привет дроид"
- }
-}
-```
-
-### B. Диагностика
-
-```bash
-# Проверка портов
-lsof -i :8675
-lsof -i :10700
-lsof -i :10500
-
-# Проверка подключений
-netstat -tlnp | grep python
-
-# Логи satellite
-journalctl -u wyoming-satelite-vosk.service -f
-
-# Тест подключения
-telnet 127.0.0.1 8675
-```
-
-### C. Сравнение подходов
-
-| Аспект | Старый (отдельный сервис) | Новый (встроенный) |
-|--------|---------------------------|--------------------|
-| Процессы | 2 (satellite + websocket-service) | 1 (satellite) |
-| Подключения | satellite → websocket → clients | clients → satellite |
-| Отказоустойчивость | Падение websocket → переподключение | Падение client → satellite работает |
-| Масштабируемость | Ограничено | Неограниченно клиентов |
-| Сложность | Выше (два сервиса) | Ниже (один сервис) |
-| Обратная совместимость | ✅ Работает через event.uri | ✅ Работает через event.uri |
-
----
-
-**Конец документа**
+- **ALSA card hardcoded** — `-c 2` в `WakeStreamingSatellite` (см. `satellite.py` ~1700)
+- **`_kill_aplay_processes()`** — может убить сторонние aplay процессы
+- **Recording buffer не ограничен** — длительная запись ест память
+- **`trigger_event`** — создаёт сырой `Event`, без валидации Wyoming
diff --git a/web-client/assist.html b/web-client/assist.html
new file mode 100644
index 0000000..f828248
--- /dev/null
+++ b/web-client/assist.html
@@ -0,0 +1,675 @@
+
+
+
+
+
+ NOVA Assistant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
В ОЖИДАНИИ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💬 Последняя фраза
+
—
+
+
+
+
+
+
+
+
diff --git a/web-client/client.html b/web-client/client.html
index 6898154..599a9e9 100644
--- a/web-client/client.html
+++ b/web-client/client.html
@@ -335,14 +335,51 @@
- 🔊 VOLUME
+ 🔊 SPEAKER
0.80
+
+
+ 🎤 MICROPHONE
+ 1.00
+
+
+
+
+
+
+
+ 🎙️ AUDIO RECORDING
+ ⏹️ STOPPED
+
+
+
+
+
+
+ 0.0s • 0 KB
+
+
+
+
+
+
+ 🎵 ПОСЛЕДНЯЯ ЗАПИСЬ
+ —
+
+
+
+
+
+
+
+
+
@@ -356,9 +393,10 @@
-
-
-
+
+
+
+
@@ -409,6 +447,7 @@
let source = null;
let mediaStream = null;
let rmsTimeout = null;
+ let currentRms = 0.0; // ✅ Текущий RMS уровень от satellite
const canvas = document.getElementById('audioCanvas');
const ctx = canvas.getContext('2d');
@@ -492,14 +531,76 @@
addEventToLog(`📤 cmd: ${command}`);
}
+ // Recording state
+ let isRecording = false;
+ let recordingStartTime = null;
+ let lastRecordingBlob = null;
+ let lastRecordingUrl = null;
+ let lastRecordingFilename = null;
+
// Обработчик входящих событий от satellite
function handleIncomingMessage(ev) {
try {
const json = JSON.parse(ev.data);
const eventType = json.type || json.event;
+ // Recording events
+ if(eventType === 'recording-ready') {
+ if(json.data) {
+ const duration = json.data.duration || 0;
+ const size = json.data.size || 0;
+ const base64Data = json.data.data;
+
+ addEventToLog(`🎙️ Запись готова: ${duration.toFixed(1)}s, ${(size/1024).toFixed(1)} KB`);
+
+ // Декодировать base64 и создать blob
+ try {
+ const audioData = atob(base64Data);
+ const bytes = new Uint8Array(audioData.length);
+ for (let i = 0; i < audioData.length; i++) {
+ bytes[i] = audioData.charCodeAt(i);
+ }
+ const blob = new Blob([bytes], { type: 'audio/wav' });
+
+ // Сохранить для плеера
+ if(lastRecordingUrl) {
+ URL.revokeObjectURL(lastRecordingUrl);
+ }
+ lastRecordingBlob = blob;
+ lastRecordingUrl = URL.createObjectURL(blob);
+ lastRecordingFilename = `recording_${new Date().toISOString().replace(/[:.]/g, '-')}.wav`;
+
+ // Показать плеер
+ const audioPlayer = document.getElementById('audioPlayer');
+ const audioPlayerContainer = document.getElementById('audioPlayerContainer');
+ const lastRecordingName = document.getElementById('lastRecordingName');
+
+ console.log('🎵 Showing audio player:', {
+ audioPlayer: !!audioPlayer,
+ audioPlayerContainer: !!audioPlayerContainer,
+ lastRecordingName: !!lastRecordingName,
+ url: lastRecordingUrl
+ });
+
+ if(audioPlayer && audioPlayerContainer && lastRecordingName) {
+ audioPlayer.src = lastRecordingUrl;
+ audioPlayerContainer.style.display = 'block';
+ lastRecordingName.innerText = lastRecordingFilename;
+ addEventToLog(`🎵 Запись готова к прослушиванию`);
+ console.log('✅ Audio player shown successfully');
+ } else {
+ console.error('❌ Audio player elements not found!');
+ addEventToLog(`❌ Плеер не найден на странице`);
+ }
+ } catch(e) {
+ console.error('❌ Recording decode error:', e);
+ addEventToLog(`❌ Ошибка декодирования записи: ${e.message}`);
+ }
+ }
+ }
+
// Audio level events
- if(eventType === 'audio-level') {
+ else if(eventType === 'audio-level') {
if(json.data && json.data.level !== undefined) {
const level = json.data.level;
currentRms = level; // ✅ Обновляем currentRms для визуализации
@@ -727,94 +828,271 @@
}
}
- // Кнопки подключения
- document.getElementById('connectBtn').addEventListener('click', toggleConnection);
- document.getElementById('connectBtnCard').addEventListener('click', toggleConnection);
-
- // Auto-connect при загрузке
+ // ✅ ЕДИНЫЙ ОБРАБОТЧИК ЗАГРУЗКИ DOM
window.addEventListener('load', () => {
+ console.log('🚀 DOM loaded, initializing...');
+
addEventToLog('🤖 Wyoming Droid Assistant • готов к подключению');
- // Инициализация RMS элементов после загрузки DOM
+ // Инициализация RMS элементов
const rmsFillEl = document.getElementById('rmsFill');
const rmsValueEl = document.getElementById('rmsValue');
if (rmsFillEl && rmsValueEl) {
- console.log('RMS meter initialized');
+ console.log('✅ RMS meter initialized');
}
+ // ✅ РЕГИСТРАЦИЯ ВСЕХ КНОПОК
+ console.log('🔘 Registering button handlers...');
+
+ // Кнопки подключения
+ const connectBtn = document.getElementById('connectBtn');
+ const connectBtnCard = document.getElementById('connectBtnCard');
+ if (connectBtn) connectBtn.addEventListener('click', toggleConnection);
+ if (connectBtnCard) connectBtnCard.addEventListener('click', toggleConnection);
+
+ // Volume control buttons
+ const startListeningBtn = document.getElementById('startListeningBtn');
+ const stopPipelineBtn = document.getElementById('stopPipelineBtn');
+ const stopTimerBtn = document.getElementById('stopTimerBtn');
+ const muteBtn = document.getElementById('muteBtn');
+ const unmuteBtn = document.getElementById('unmuteBtn');
+ const volUpBtn = document.getElementById('volUpBtn');
+ const volDownBtn = document.getElementById('volDownBtn');
+ const requestRmsBtn = document.getElementById('requestRmsBtn');
+ const clearEventsBtn = document.getElementById('clearEvents');
+
+ if (startListeningBtn) startListeningBtn.addEventListener('click', () => sendCommand('set_volume', { level: 1.0 }));
+ if (stopPipelineBtn) stopPipelineBtn.addEventListener('click', () => sendCommand('mute', {}));
+ if (stopTimerBtn) stopTimerBtn.addEventListener('click', () => sendCommand('unmute', {}));
+ if (muteBtn) muteBtn.addEventListener('click', () => sendCommand('mute', {}));
+ if (unmuteBtn) unmuteBtn.addEventListener('click', () => sendCommand('unmute', {}));
+
+ if (volUpBtn) volUpBtn.addEventListener('click', () => {
+ const newVol = Math.min(1.0, currentVolume + 0.1);
+ currentVolume = newVol;
+ sendCommand('set_volume', { level: newVol });
+ volumeSlider.value = newVol;
+ volumeValueSpan.innerText = newVol.toFixed(2);
+ });
+
+ if (volDownBtn) volDownBtn.addEventListener('click', () => {
+ const newVol = Math.max(0.0, currentVolume - 0.1);
+ currentVolume = newVol;
+ sendCommand('set_volume', { level: newVol });
+ volumeSlider.value = newVol;
+ volumeValueSpan.innerText = newVol.toFixed(2);
+ });
+
+ if (requestRmsBtn) requestRmsBtn.addEventListener('click', () => {
+ sendCommand('trigger_event', { event_type: 'request_audio_level', data: {} });
+ addEventToLog('📊 Запрошено обновление RMS');
+ });
+
+ // Gesture buttons
+ const singlePressBtn = document.getElementById('singlePressBtn');
+ const doublePressBtn = document.getElementById('doublePressBtn');
+ const triplePressBtn = document.getElementById('triplePressBtn');
+ const longPressBtn = document.getElementById('longPressBtn');
+
+ if (singlePressBtn) singlePressBtn.addEventListener('click', () => sendCommand('trigger_event', { event_type: 'single_press', data: {} }));
+ if (doublePressBtn) doublePressBtn.addEventListener('click', () => sendCommand('trigger_event', { event_type: 'double_press', data: {} }));
+ if (triplePressBtn) triplePressBtn.addEventListener('click', () => sendCommand('trigger_event', { event_type: 'triple_press', data: {} }));
+ if (longPressBtn) longPressBtn.addEventListener('click', () => sendCommand('trigger_event', { event_type: 'long_press', data: {} }));
+
+ if (clearEventsBtn) clearEventsBtn.addEventListener('click', () => {
+ while(eventFeedDiv.children.length > 1) eventFeedDiv.removeChild(eventFeedDiv.lastChild);
+ addEventToLog('🧹 Лог очищен');
+ });
+
+ // ✅ РЕГИСТРАЦИЯ СЛАЙДЕРОВ
+ const slider = document.getElementById('volumeSlider');
+ if (slider) {
+ console.log('✅ Volume slider found, attaching event listener');
+ slider.addEventListener('input', (e) => {
+ const val = parseFloat(e.target.value);
+ console.log('🎚️ Speaker slider changed:', val);
+ currentVolume = val;
+ volumeValueSpan.innerText = val.toFixed(2);
+ liveVolSpan.innerText = Math.round(val * 100) + '%';
+ console.log('📤 Sending set_volume command:', val);
+ sendCommand('set_volume', { level: val });
+ });
+ console.log('✅ Speaker slider event listener attached successfully');
+ } else {
+ console.error('❌ Volume slider NOT FOUND!');
+ }
+
+ // ✅ РЕГИСТРАЦИЯ СЛАЙДЕРА МИКРОФОНА
+ let currentMicVolume = 1.0;
+ const micSlider = document.getElementById('micVolumeSlider');
+ const micVolumeValueSpan = document.getElementById('micVolumeValue');
+
+ if (micSlider && micVolumeValueSpan) {
+ console.log('✅ Microphone slider found, attaching event listener');
+ micSlider.addEventListener('input', (e) => {
+ const val = parseFloat(e.target.value);
+ console.log('🎤 Mic slider changed:', val);
+ currentMicVolume = val;
+ micVolumeValueSpan.innerText = val.toFixed(2);
+ console.log('📤 Sending set_mic_volume command:', val);
+ sendCommand('set_mic_volume', { level: val });
+ });
+ console.log('✅ Microphone slider event listener attached successfully');
+ } else {
+ console.error('❌ Microphone slider NOT FOUND!');
+ }
+
+ // ✅ КНОПКИ MIC+ / MIC-
+ const micUpBtn = document.getElementById('micUpBtn');
+ const micDownBtn = document.getElementById('micDownBtn');
+
+ if (micUpBtn) {
+ micUpBtn.addEventListener('click', () => {
+ const newVol = Math.min(2.0, currentMicVolume + 0.1);
+ currentMicVolume = newVol;
+ sendCommand('set_mic_volume', { level: newVol });
+ micSlider.value = newVol;
+ micVolumeValueSpan.innerText = newVol.toFixed(2);
+ addEventToLog(`🎤 Чувствительность микрофона: ${Math.round(newVol * 100)}%`);
+ });
+ console.log('✅ MIC+ button registered');
+ }
+
+ if (micDownBtn) {
+ micDownBtn.addEventListener('click', () => {
+ const newVol = Math.max(0.0, currentMicVolume - 0.1);
+ currentMicVolume = newVol;
+ sendCommand('set_mic_volume', { level: newVol });
+ micSlider.value = newVol;
+ micVolumeValueSpan.innerText = newVol.toFixed(2);
+ addEventToLog(`🎤 Чувствительность микрофона: ${Math.round(newVol * 100)}%`);
+ });
+ console.log('✅ MIC- button registered');
+ }
+
+ // ✅ КНОПКИ ЗАПИСИ
+ const startRecordingBtn = document.getElementById('startRecordingBtn');
+ const stopRecordingBtn = document.getElementById('stopRecordingBtn');
+ const recordingStatus = document.getElementById('recordingStatus');
+ const recordingInfo = document.getElementById('recordingInfo');
+ const recordingDuration = document.getElementById('recordingDuration');
+ const recordingSize = document.getElementById('recordingSize');
+
+ if (startRecordingBtn && stopRecordingBtn) {
+ startRecordingBtn.addEventListener('click', () => {
+ if(!isConnected) {
+ addEventToLog('❌ Нет подключения к satellite');
+ return;
+ }
+
+ sendCommand('start_recording', {});
+ isRecording = true;
+ recordingStartTime = Date.now();
+
+ // Обновить UI
+ startRecordingBtn.disabled = true;
+ stopRecordingBtn.disabled = false;
+ recordingStatus.innerText = '🔴 RECORDING';
+ recordingStatus.style.color = '#ff4d4d';
+ recordingInfo.style.display = 'block';
+
+ // Обновлять длительность каждую секунду
+ const updateDuration = setInterval(() => {
+ if(!isRecording) {
+ clearInterval(updateDuration);
+ return;
+ }
+ const elapsed = (Date.now() - recordingStartTime) / 1000;
+ recordingDuration.innerText = elapsed.toFixed(1) + 's';
+ }, 100);
+
+ addEventToLog('🔴 Запись началась');
+ });
+
+ stopRecordingBtn.addEventListener('click', () => {
+ if(!isRecording) return;
+
+ sendCommand('stop_recording', {});
+ isRecording = false;
+
+ // Обновить UI
+ startRecordingBtn.disabled = false;
+ stopRecordingBtn.disabled = true;
+ recordingStatus.innerText = '⏹️ STOPPED';
+ recordingStatus.style.color = '#aaa';
+
+ addEventToLog('⏹️ Запись остановлена, ожидание файла...');
+ });
+
+ console.log('✅ Recording buttons registered');
+ }
+
+ // ✅ КНОПКИ ПЛЕЕРА
+ const downloadRecordingBtn = document.getElementById('downloadRecordingBtn');
+ const deleteRecordingBtn = document.getElementById('deleteRecordingBtn');
+ const audioPlayerContainer = document.getElementById('audioPlayerContainer');
+ const audioPlayer = document.getElementById('audioPlayer');
+
+ if (downloadRecordingBtn) {
+ downloadRecordingBtn.addEventListener('click', () => {
+ if(!lastRecordingBlob || !lastRecordingFilename) {
+ addEventToLog('❌ Нет записи для скачивания');
+ return;
+ }
+
+ const url = URL.createObjectURL(lastRecordingBlob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = lastRecordingFilename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ addEventToLog(`💾 Файл скачан: ${lastRecordingFilename}`);
+ });
+ console.log('✅ Download recording button registered');
+ }
+
+ if (deleteRecordingBtn && audioPlayerContainer && audioPlayer) {
+ deleteRecordingBtn.addEventListener('click', () => {
+ if(!lastRecordingBlob) {
+ addEventToLog('❌ Нет записи для удаления');
+ return;
+ }
+
+ // Остановить воспроизведение
+ audioPlayer.pause();
+ audioPlayer.src = '';
+
+ // Очистить данные
+ if(lastRecordingUrl) {
+ URL.revokeObjectURL(lastRecordingUrl);
+ }
+ lastRecordingBlob = null;
+ lastRecordingUrl = null;
+ lastRecordingFilename = null;
+
+ // Скрыть плеер
+ audioPlayerContainer.style.display = 'none';
+
+ addEventToLog('🗑️ Запись удалена');
+ });
+ console.log('✅ Delete recording button registered');
+ }
+
+ console.log('✅ All handlers registered');
+
// Auto-connect если включена опция
const autoConnect = document.getElementById('autoConnect');
if(autoConnect && autoConnect.checked) {
setTimeout(() => {
+ console.log('🔌 Auto-connecting...');
addEventToLog('📡 Auto-connect...');
toggleConnection();
}, 500);
}
});
-
- // Volume control
- document.getElementById('startListeningBtn').addEventListener('click', () => sendCommand('set_volume', { level: 1.0 }));
- document.getElementById('stopPipelineBtn').addEventListener('click', () => sendCommand('mute', {}));
- document.getElementById('stopTimerBtn').addEventListener('click', () => sendCommand('unmute', {}));
- document.getElementById('muteBtn').addEventListener('click', () => sendCommand('mute', {}));
- document.getElementById('unmuteBtn').addEventListener('click', () => sendCommand('unmute', {}));
-
- document.getElementById('volUpBtn').addEventListener('click', () => {
- const newVol = Math.min(1.0, currentVolume + 0.1);
- currentVolume = newVol; // ✅ Обновляем currentVolume
- sendCommand('set_volume', { level: newVol });
- volumeSlider.value = newVol;
- volumeValueSpan.innerText = newVol.toFixed(2);
- });
-
- document.getElementById('volDownBtn').addEventListener('click', () => {
- const newVol = Math.max(0.0, currentVolume - 0.1);
- currentVolume = newVol; // ✅ Обновляем currentVolume
- sendCommand('set_volume', { level: newVol });
- volumeSlider.value = newVol;
- volumeValueSpan.innerText = newVol.toFixed(2);
- });
-
- // Request RMS button
- document.getElementById('requestRmsBtn').addEventListener('click', () => {
- // Запросить обновление audio-level (сателит отправляет их автоматически)
- // Отправляем dummy команду для триггера
- sendCommand('trigger_event', { event_type: 'request_audio_level', data: {} });
- addEventToLog('📊 Запрошено обновление RMS');
- });
-
- document.getElementById('pauseMediaBtn').addEventListener('click', () => sendCommand('play_sound', { path: '/home/sirmrak/projects/satellite-dev/sounds/awake.wav' }));
- document.getElementById('resumeMediaBtn').addEventListener('click', () => sendCommand('play_sound', { path: '/home/sirmrak/projects/satellite-dev/sounds/done.wav' }));
- document.getElementById('stopMediaBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'stop', data: {} }));
-
- document.getElementById('singlePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'single_press', data: {} }));
- document.getElementById('doublePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'double_press', data: {} }));
- document.getElementById('triplePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'triple_press', data: {} }));
- document.getElementById('longPressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'long_press', data: {} }));
-
- document.getElementById('clearEvents').addEventListener('click', () => {
- while(eventFeedDiv.children.length > 1) eventFeedDiv.removeChild(eventFeedDiv.lastChild);
- addEventToLog('🧹 Лог очищен');
- });
-
- volumeSlider.addEventListener('input', (e) => {
- const val = parseFloat(e.target.value);
- volumeValueSpan.innerText = val.toFixed(2);
- currentVolume = val;
- sendCommand('set_volume', { level: val });
- });
-
- // автоподключение при загрузке (опционально)
- window.addEventListener('load', () => {
- addEventToLog('🤖 Интерфейс LVA NEXUS • готов к подключению');
-
- // Инициализация RMS элементов после загрузки DOM
- const rmsFillEl = document.getElementById('rmsFill');
- const rmsValueEl = document.getElementById('rmsValue');
- if (rmsFillEl && rmsValueEl) {
- console.log('RMS meter initialized');
- }
- });