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 + + + + + +
+ + +
+
+ Отключено +
+ + +
+ + +
+
+
+ +
+
+
+ + +
В ОЖИДАНИИ
+ + +
+
+ 🔊 Громкость + 80% +
+ +
+ + +
+
+ + +
+
💬 Последняя фраза
+
—
+
+ +
+ + + + + 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 +
+
+ + +
+ + + + +
@@ -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'); - } - }); - \ No newline at end of file + diff --git a/web-client/html/index.html b/web-client/html/index.html new file mode 100644 index 0000000..11b7d1e --- /dev/null +++ b/web-client/html/index.html @@ -0,0 +1,128 @@ + + + + + + NOVA Voice Assistant + + + + +
+ +
+ +
+
+ disconnected + +
+
+ + +
+
+ + + +
+
+ + +
+ +
+
+ 🎤 +

Voice Core

+
+
+
+
+
🎙️
+
+
Idle
+
+ +
+ +
+ +
+
RMS Level
+
+
+
+
0.00
+
+ +
+ 🔊 Volume + + 80% +
+
+ + +
+
+ + +
+
+ 🕹️ +

Commands & Transcripts

+
+
+ + + +
+
+ + + +
+
+ + + + +
+
+
+
You said:
+
—
+
+
+
Assistant said:
+
—
+
+
+
+ + +
+
+ 📡 +

Telemetry & Events

+
+
+
🔇 Muted: —
+
🏠 HA: —
+
🎚️ Vol: —
+
+
+
⬤ Ready to connect...
+
+ +
+
+
+ + \ No newline at end of file diff --git a/web-client/html/script.js b/web-client/html/script.js new file mode 100644 index 0000000..9836145 --- /dev/null +++ b/web-client/html/script.js @@ -0,0 +1,255 @@ +// ------------------ WebSocket & State ------------------ +let ws = null; +let isConnected = false; +let currentVolume = 0.8; +let mutedState = false; +let haConnected = false; +let rmsTimeout = null; +let currentRms = 0; + +const canvas = document.getElementById('audioCanvas'); +const ctx = canvas.getContext('2d'); +let canvasWidth = canvas.parentElement.clientWidth - 24; +let canvasHeight = 80; +canvas.width = canvasWidth; +canvas.height = canvasHeight; + +// DOM elements +const statusLed = document.getElementById('statusLed'); +const statusLabel = document.getElementById('statusLabel'); +const connectBtn = document.getElementById('connectBtn'); +const orbCore = document.getElementById('orbCore'); +const stateText = document.getElementById('stateText'); +const volumeSlider = document.getElementById('volumeSlider'); +const volumeValueSpan = document.getElementById('volumeValue'); +const lastSttSpan = document.getElementById('lastStt'); +const lastTtsSpan = document.getElementById('lastTts'); +const mutedStatusSpan = document.getElementById('mutedStatus'); +const haStatusSpan = document.getElementById('haStatus'); +const liveVolSpan = document.getElementById('liveVol'); +const eventFeedDiv = document.getElementById('eventFeed'); +const rmsFill = document.getElementById('rmsFill'); +const rmsValueSpan = document.getElementById('rmsValue'); + +function addEventToLog(message) { + const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const div = document.createElement('div'); + div.className = 'event-item'; + div.innerHTML = `${time} • ${message}`; + eventFeedDiv.prepend(div); + if (eventFeedDiv.children.length > 50) eventFeedDiv.removeChild(eventFeedDiv.lastChild); +} + +function updateRmsMeter(level) { + if (!rmsFill || !rmsValueSpan) return; + const percent = Math.min(100, Math.max(0, level * 100)); + rmsFill.style.width = percent + '%'; + rmsValueSpan.textContent = level.toFixed(2); + clearTimeout(rmsTimeout); + rmsTimeout = setTimeout(() => { + if (rmsFill) rmsFill.style.width = '0%'; + if (rmsValueSpan) rmsValueSpan.textContent = '0.00'; + }, 1000); +} + +function sendCommand(command, payload = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + addEventToLog(`❌ Cannot send: ${command} — not connected`); + return; + } + const msg = JSON.stringify({ command, data: payload }); + ws.send(msg); + addEventToLog(`📤 Command: ${command}`); +} + +function handleIncomingMessage(ev) { + try { + const json = JSON.parse(ev.data); + const eventType = json.type || json.event; + + if (eventType === 'audio-level' && json.data?.level !== undefined) { + currentRms = json.data.level; + updateRmsMeter(currentRms); + } else if (eventType === 'voice-started') { + stateText.innerText = 'Listening...'; + document.body.classList.add('listening-active'); + addEventToLog('🎤 Voice detected'); + } else if (eventType === 'voice-stopped') { + stateText.innerText = 'Processing'; + document.body.classList.remove('listening-active'); + addEventToLog('💭 Processing speech'); + } else if (eventType === 'transcript' && json.data?.text) { + lastSttSpan.innerText = json.data.text; + addEventToLog(`🎤 "${json.data.text}"`); + } else if (eventType === 'synthesize' && json.data?.text) { + lastTtsSpan.innerText = json.data.text; + } else if (eventType === 'audio-start') { + stateText.innerText = 'Speaking'; + addEventToLog('🗣️ Assistant speaking'); + } else if (eventType === 'audio-stop' || eventType === 'played') { + if (stateText.innerText === 'Speaking') stateText.innerText = 'Idle'; + addEventToLog('🔇 TTS finished'); + } else if (eventType === 'timer-started') { + addEventToLog('⏱️ Timer started'); + } else if (eventType === 'timer-finished') { + addEventToLog('⏰ Timer triggered!'); + stateText.innerText = 'Timer'; + } else if (eventType === 'satellite-connected') { + haConnected = true; + haStatusSpan.innerText = 'connected'; + addEventToLog('🔌 Satellite connected'); + } else if (eventType === 'satellite-disconnected') { + haConnected = false; + haStatusSpan.innerText = 'offline'; + addEventToLog('🔌 Satellite disconnected'); + } else if (eventType === 'error') { + addEventToLog(`❌ Error: ${json.data?.message || 'unknown'}`); + } else { + addEventToLog(`📡 Event: ${eventType}`); + } + + if (json.data) { + if (json.data.volume !== undefined) { + currentVolume = json.data.volume; + volumeSlider.value = currentVolume; + volumeValueSpan.innerText = Math.round(currentVolume * 100) + '%'; + liveVolSpan.innerText = Math.round(currentVolume * 100) + '%'; + } + if (json.data.muted !== undefined) { + mutedState = json.data.muted; + mutedStatusSpan.innerText = mutedState ? 'muted' : 'unmuted'; + } + } + } catch (e) { + console.warn(e); + addEventToLog(`⚠️ Parse error: ${e.message}`); + } +} + +// Визуализация аудио (волны) +let rmsSmoothed = 0; +function drawVisualization() { + requestAnimationFrame(drawVisualization); + if (!isConnected) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#0a1222'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#0ff3'; + ctx.font = '10px monospace'; + ctx.fillText('⚡ idle', canvas.width - 50, 20); + return; + } + let avg = 10 + (currentRms / 0.15) * 90; + avg = Math.min(100, Math.max(10, avg)); + rmsSmoothed = rmsSmoothed * 0.7 + avg * 0.3; + avg = rmsSmoothed; + if (currentRms < 0.001) avg = 10 + Math.random() * 5; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#07121f'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const barWidth = canvas.width / 42; + for (let i = 0; i < 42; i++) { + let barHeight = (avg * (Math.sin(i * 0.5) + 1) / 2 + 5) * (0.5 + Math.random() * 0.3); + if (stateText.innerText === 'Speaking') barHeight = avg * 1.6 * (Math.sin(i * 0.3) + 1.2); + barHeight = Math.min(barHeight, canvas.height - 10); + const x = i * (barWidth + 2); + const grad = ctx.createLinearGradient(x, canvas.height - barHeight, x, canvas.height); + grad.addColorStop(0, '#0ff'); + grad.addColorStop(1, '#a3f'); + ctx.fillStyle = grad; + ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); + } +} +drawVisualization(); + +// WebSocket подключение +function toggleConnection() { + const url = document.getElementById('wsUrlInput').value.trim(); + if (!url) { + addEventToLog('❌ Enter WebSocket URL'); + return; + } + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + return; + } + ws = new WebSocket(url); + ws.onopen = () => { + isConnected = true; + statusLed.classList.add('connected'); + statusLabel.innerText = 'connected'; + addEventToLog(`✅ Connected to ${url}`); + stateText.innerText = 'Online'; + document.getElementById('connectBtn').innerHTML = '🔌 Disconnect'; + document.getElementById('connectBtnCard').innerHTML = 'Disconnect'; + }; + ws.onmessage = handleIncomingMessage; + ws.onerror = () => addEventToLog(`⚠️ WebSocket error`); + ws.onclose = () => { + isConnected = false; + statusLed.classList.remove('connected'); + statusLabel.innerText = 'disconnected'; + addEventToLog(`🔌 Disconnected`); + stateText.innerText = 'Offline'; + document.body.classList.remove('listening-active'); + document.getElementById('connectBtn').innerHTML = '⟳ Connect'; + document.getElementById('connectBtnCard').innerHTML = 'Connect'; + }; +} + +// Обработчики кнопок +document.getElementById('connectBtn').addEventListener('click', toggleConnection); +document.getElementById('connectBtnCard').addEventListener('click', toggleConnection); + +window.addEventListener('load', () => { + addEventToLog('🤖 Nova Assistant ready'); + if (document.getElementById('autoConnect').checked) { + setTimeout(() => toggleConnection(), 500); + } + // Адаптация canvas при изменении размера + window.addEventListener('resize', () => { + canvas.width = canvas.parentElement.clientWidth - 24; + canvas.height = 80; + }); +}); + +// Команды +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', () => { + let newVol = Math.min(1.0, currentVolume + 0.1); + currentVolume = newVol; + sendCommand('set_volume', { level: newVol }); + volumeSlider.value = newVol; + volumeValueSpan.innerText = Math.round(newVol * 100) + '%'; +}); +document.getElementById('volDownBtn').addEventListener('click', () => { + let newVol = Math.max(0.0, currentVolume - 0.1); + currentVolume = newVol; + sendCommand('set_volume', { level: newVol }); + volumeSlider.value = newVol; + volumeValueSpan.innerText = Math.round(newVol * 100) + '%'; +}); +document.getElementById('requestRmsBtn').addEventListener('click', () => { + sendCommand('trigger_event', { event_type: 'request_audio_level', data: {} }); + addEventToLog('📊 Requested RMS update'); +}); +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('🧹 Log cleared'); +}); +volumeSlider.addEventListener('input', (e) => { + let val = parseFloat(e.target.value); + currentVolume = val; + volumeValueSpan.innerText = Math.round(val * 100) + '%'; + sendCommand('set_volume', { level: val }); +}); \ No newline at end of file diff --git a/web-client/html/styles.css b/web-client/html/styles.css new file mode 100644 index 0000000..207e8bc --- /dev/null +++ b/web-client/html/styles.css @@ -0,0 +1,456 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + user-select: none; +} + +body { + background: radial-gradient(circle at 10% 20%, #0b1120, #04070c); + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Poppins', sans-serif; + min-height: 100vh; + padding: 24px; + color: #eef5ff; +} + +.app-container { + max-width: 1400px; + margin: 0 auto; +} + +/* Glassmorphism */ +.glass { + background: rgba(15, 25, 45, 0.55); + backdrop-filter: blur(16px); + border-radius: 32px; + border: 1px solid rgba(0, 255, 255, 0.15); + box-shadow: 0 20px 35px -10px rgba(0, 0, 0, 0.4); + transition: all 0.2s ease; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; + font-size: 1.8rem; + font-weight: 700; + background: linear-gradient(135deg, #a0f0ff, #6c63ff); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.logo-icon { + font-size: 2rem; + filter: drop-shadow(0 0 6px cyan); +} + +.logo-text { + letter-spacing: 1px; +} + +.connection-status { + display: flex; + align-items: center; + gap: 16px; + background: rgba(0, 0, 0, 0.3); + padding: 8px 20px; + border-radius: 60px; +} + +.led { + width: 12px; + height: 12px; + border-radius: 50%; + background: #ff4d4d; + box-shadow: 0 0 6px #ff4d4d; + transition: 0.2s; +} + +.led.connected { + background: #0ff; + box-shadow: 0 0 12px #0ff; +} + +.status-text { + font-weight: 500; + font-family: monospace; + font-size: 0.9rem; +} + +.settings-card { + padding: 16px 24px; + margin-bottom: 28px; +} + +.settings-row { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.input-url { + flex: 1; + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(0, 255, 255, 0.3); + border-radius: 40px; + padding: 10px 18px; + color: #eef5ff; + font-size: 0.9rem; + outline: none; + transition: 0.2s; +} + +.input-url:focus { + border-color: cyan; + box-shadow: 0 0 8px rgba(0, 255, 255, 0.3); +} + +.auto-connect { + display: flex; + align-items: center; + gap: 8px; + color: #aaa; + font-size: 0.85rem; + cursor: pointer; +} + +.dashboard { + display: grid; + grid-template-columns: 1fr 1.1fr 0.9fr; + gap: 24px; +} + +.card { + padding: 24px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 25px 40px -12px rgba(0, 0, 0, 0.5); +} + +.card-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + border-left: 3px solid #0ff; + padding-left: 14px; +} + +.card-header h3 { + font-weight: 500; + font-size: 1.1rem; + letter-spacing: 0.5px; + color: #bfd9ff; +} + +.header-icon { + font-size: 1.3rem; +} + +/* Голосовая сфера */ +.avatar-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 20px 0; +} + +.orb { + position: relative; + width: 150px; + height: 150px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; +} + +.orb-inner { + width: 130px; + height: 130px; + background: radial-gradient(circle at 35% 35%, #1ad, #0a4); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 3.5rem; + box-shadow: 0 0 30px rgba(0, 255, 200, 0.4); + transition: all 0.2s ease; + z-index: 2; +} + +.orb-pulse { + position: absolute; + width: 150px; + height: 150px; + border-radius: 50%; + background: rgba(0, 255, 200, 0.2); + animation: pulse-ring 2s infinite; + z-index: 1; +} + +@keyframes pulse-ring { + 0% { transform: scale(0.9); opacity: 0.6; } + 100% { transform: scale(1.4); opacity: 0; } +} + +/* Активное слушание */ +.listening-active .orb-inner { + animation: listen-pulse 0.8s infinite alternate; + box-shadow: 0 0 50px cyan; +} + +@keyframes listen-pulse { + 0% { transform: scale(1); background: radial-gradient(circle at 35% 35%, #1ad, #0a4); } + 100% { transform: scale(1.05); background: radial-gradient(circle at 35% 35%, #3ff, #2c9); } +} + +.state-badge { + background: rgba(0, 255, 255, 0.15); + backdrop-filter: blur(4px); + padding: 6px 18px; + border-radius: 40px; + font-weight: 600; + font-size: 0.9rem; +} + +/* Визуализатор */ +.viz-wrapper { + background: rgba(0, 0, 0, 0.3); + border-radius: 24px; + padding: 12px; + margin: 15px 0; +} + +canvas { + width: 100%; + height: 80px; + display: block; + border-radius: 16px; +} + +/* RMS meter */ +.rms-container { + margin: 16px 0; +} + +.rms-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 1px; + color: #8aa; + margin-bottom: 6px; +} + +.rms-bar { + height: 6px; + background: #1e2a3a; + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; +} + +.rms-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #0ff, #f0f); + border-radius: 3px; + transition: width 0.08s linear; +} + +.rms-value { + font-family: monospace; + font-size: 0.8rem; + text-align: right; + color: cyan; +} + +.volume-control { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0 12px; +} + +.volume-control span:first-child { + font-size: 0.9rem; +} + +.volume-control input { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; /* стандартное свойство для совместимости */ + background: #2a3a4a; + border-radius: 2px; + outline: none; +} + +.volume-control input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: cyan; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 6px cyan; +} + +.volume-percent { + width: 45px; + text-align: right; + font-family: monospace; +} + +.button-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 16px 0 8px; +} + +.btn-primary, .btn-secondary, .btn-danger, .btn-outline { + padding: 8px 18px; + border-radius: 40px; + font-weight: 500; + font-size: 0.8rem; + cursor: pointer; + transition: 0.15s; + border: none; + background: rgba(20, 30, 50, 0.7); + backdrop-filter: blur(4px); + color: #eef5ff; + display: inline-flex; + align-items: center; + gap: 8px; + letter-spacing: 0.5px; + border: 1px solid rgba(0, 255, 255, 0.3); +} + +.btn-primary { + background: rgba(0, 200, 255, 0.2); + border-color: #0ff; +} + +.btn-primary:hover { + background: rgba(0, 200, 255, 0.4); + box-shadow: 0 0 10px rgba(0, 255, 255, 0.4); + transform: translateY(-2px); +} + +.btn-secondary:hover { + background: rgba(100, 100, 200, 0.3); + border-color: #8af; + transform: translateY(-2px); +} + +.btn-danger { + border-color: #ff66aa; +} +.btn-danger:hover { + background: #ff336620; + border-color: #ff88cc; +} + +.btn-outline { + background: transparent; + border-color: #6c7a8a; +} +.btn-outline:hover { + border-color: cyan; + background: rgba(0, 255, 255, 0.1); +} + +.full-width { + width: 100%; + justify-content: center; + margin-top: 12px; +} + +.transcript-area { + background: rgba(0, 0, 0, 0.3); + border-radius: 24px; + padding: 16px; + margin-top: 18px; +} + +.transcript-item { + margin-bottom: 14px; +} + +.transcript-label { + font-size: 0.7rem; + text-transform: uppercase; + color: #8ad; + margin-bottom: 6px; +} + +.transcript-text { + font-style: italic; + color: #f0f9ff; + word-break: break-word; + font-size: 0.9rem; +} + +.status-grid { + display: flex; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.status-chip { + background: rgba(0, 0, 0, 0.3); + padding: 6px 14px; + border-radius: 40px; + font-size: 0.8rem; +} + +.event-feed { + background: rgba(0, 0, 0, 0.25); + border-radius: 20px; + height: 220px; + overflow-y: auto; + padding: 10px; + font-family: monospace; + font-size: 0.75rem; +} + +.event-item { + padding: 8px 0; + border-bottom: 1px solid #2a4c6e50; + color: #bde; +} + +@media (max-width: 1000px) { + .dashboard { + grid-template-columns: 1fr; + gap: 20px; + } + body { + padding: 16px; + } + .orb { + width: 120px; + height: 120px; + } + .orb-inner { + width: 100px; + height: 100px; + font-size: 2.5rem; + } +} \ No newline at end of file diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index df5e70c..75c4e63 100755 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -113,7 +113,8 @@ class SatelliteBase: self._unmute_microphone_task: Optional[asyncio.Task] = None # Runtime настройки (изменяемые через WebSocket команды) - self._runtime_volume: float = 1.0 + self._runtime_mic_volume: float = 1.0 # Громкость микрофона + self._runtime_snd_volume: float = 1.0 # Громкость воспроизведения self._runtime_led_brightness: int = 128 # WebSocket server @@ -125,6 +126,11 @@ class SatelliteBase: self._last_mic_level: float = 0.0 self._audio_level_task: Optional[asyncio.Task] = None + # Audio recording + self._is_recording: bool = False + self._recording_buffer: List[bytes] = [] + self._recording_start_time: Optional[float] = None + # Debug audio recording self.wake_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None @@ -591,9 +597,9 @@ class SatelliteBase: audio_bytes, self.settings.mic.volume_multiplier ) - # Применить runtime громкость (из WebSocket команд) - if self._runtime_volume != 1.0: - audio_bytes = multiply_volume(audio_bytes, self._runtime_volume) + # Применить runtime громкость микрофона (из WebSocket команд) + if self._runtime_mic_volume != 1.0: + audio_bytes = multiply_volume(audio_bytes, self._runtime_mic_volume) if self._mic_webrtc is not None: # Noise suppression and auto gain @@ -653,9 +659,13 @@ class SatelliteBase: _LOGGER.debug("Connected to snd service") # Audio processing - if self.settings.snd.needs_processing and AudioChunk.is_type( - event.type - ): + # Проверяем нужна ли обработка: базовый volume_multiplier или runtime громкость + needs_processing = ( + self.settings.snd.needs_processing # volume_multiplier != 1.0 + or self._runtime_snd_volume != 1.0 # WebSocket громкость + ) + + if needs_processing and AudioChunk.is_type(event.type): chunk = AudioChunk.from_event(event) audio_bytes = self._process_snd_audio(chunk.audio) event = AudioChunk( @@ -693,6 +703,11 @@ class SatelliteBase: audio_bytes, self.settings.snd.volume_multiplier ) + # Применить runtime громкость воспроизведения (из WebSocket команд) + if self._runtime_snd_volume != 1.0: + _LOGGER.debug(f"Applying runtime volume: {self._runtime_snd_volume}") + audio_bytes = multiply_volume(audio_bytes, self._runtime_snd_volume) + return audio_bytes async def _play_wav( @@ -1149,7 +1164,8 @@ class SatelliteBase: Обработать команду от WebSocket клиента. Поддерживаемые команды: - - set_volume: Изменить громкость + - set_volume: Изменить громкость воспроизведения (TTS, звуки) + - set_mic_volume: Изменить чувствительность микрофона - mute: Заглушить микрофон - unmute: Включить микрофон - play_sound: Воспроизвести WAV файл @@ -1158,12 +1174,19 @@ class SatelliteBase: """ try: if command == "set_volume": - # Изменить громкость (0.0 - 1.0) + # Изменить громкость воспроизведения (0.0 - 1.0) # Поддерживаем оба названия: "level" и "volume" (для обратной совместимости) level = float(data.get("level") or data.get("volume", 1.0)) level = max(0.0, min(1.0, level)) # Clamp 0-1 - self._runtime_volume = level - _LOGGER.info(f"Setting volume to {level}") + self._runtime_snd_volume = level + _LOGGER.info(f"Setting playback volume to {level}") + + elif command == "set_mic_volume": + # Изменить чувствительность микрофона (0.0 - 2.0) + level = float(data.get("level") or data.get("volume", 1.0)) + level = max(0.0, min(2.0, level)) # Clamp 0-2 + self._runtime_mic_volume = level + _LOGGER.info(f"Setting microphone sensitivity to {level}") elif command == "mute": _LOGGER.info("Muting microphone") @@ -1189,6 +1212,42 @@ class SatelliteBase: self._runtime_led_brightness = brightness _LOGGER.info(f"Setting LED brightness to {brightness}") + elif command == "start_recording": + # Начать запись аудио с микрофона + _LOGGER.info("Starting audio recording") + self._is_recording = True + self._recording_buffer = [] + self._recording_start_time = time.time() + + elif command == "stop_recording": + # Остановить запись и вернуть WAV файл + if not self._is_recording: + _LOGGER.warning("Recording not started") + return + + _LOGGER.info("Stopping audio recording") + self._is_recording = False + + # Создать WAV файл из буфера + duration = time.time() - (self._recording_start_time or 0) + wav_data = await self._create_wav_from_buffer() + + # Отправить событие с данными записи + import base64 + recording_event = Event( + type="recording-ready", + data={ + "duration": duration, + "size": len(wav_data), + "data": base64.b64encode(wav_data).decode('utf-8') + } + ) + await self._broadcast_to_websocket(recording_event) + + # Очистить буфер + self._recording_buffer = [] + _LOGGER.info(f"Recording saved: {duration:.1f}s, {len(wav_data)} bytes") + elif command == "trigger_event": # Отправить событие в Home Assistant event_type = data.get("event_type") @@ -1196,9 +1255,8 @@ class SatelliteBase: if event_type: _LOGGER.info(f"Triggering event: {event_type}") # Отправить событие в server - from wyoming.event import Event - event = Event(type=event_type, data=event_data) - await self.event_to_server(event) + trigger_event = Event(type=event_type, data=event_data) + await self.event_to_server(trigger_event) else: _LOGGER.warning("trigger_event missing 'event_type'") @@ -1250,6 +1308,35 @@ class SatelliteBase: if self._last_mic_level > 0.05: _LOGGER.debug(f"Audio level: {self._last_mic_level:.2f}") + # ------------------------------------------------------------------------- + # Audio Recording + # ------------------------------------------------------------------------- + + async def _create_wav_from_buffer(self) -> bytes: + """ + Создать WAV файл из буфера записи. + + Returns: + bytes: WAV файл в виде байтов + """ + import io + + if not self._recording_buffer: + return b'' + + # Объединить все чанки + audio_data = b''.join(self._recording_buffer) + + # Создать WAV файл в памяти + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, 'wb') as wav_file: + wav_file.setnchannels(self.settings.mic.channels) + wav_file.setsampwidth(self.settings.mic.width) + wav_file.setframerate(self.settings.mic.rate) + wav_file.writeframes(audio_data) + + return wav_buffer.getvalue() + # ------------------------------------------------------------------------- # Info # ------------------------------------------------------------------------- @@ -1619,6 +1706,10 @@ class WakeStreamingSatellite(SatelliteBase): self._last_mic_level = self._calculate_rms(audio_bytes) + # Сохранить в буфер записи (если запись активна) + if self._is_recording: + self._recording_buffer.append(audio_bytes) + # Debug audio recording if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None): if self.wake_audio_writer is not None: