diff --git a/docs/development/websocket-server-plan.md b/docs/development/websocket-server-plan.md index c2e384b..b2425e1 100644 --- a/docs/development/websocket-server-plan.md +++ b/docs/development/websocket-server-plan.md @@ -30,6 +30,7 @@ ### ⏳ Ожидается +- [ ] **Этап WS-6: Audio level** — трансляция RMS громкости микрофона - [ ] Merge в master - [ ] Production развёртывание @@ -656,6 +657,198 @@ python3 -m wyoming_satellite \ --- +### Этап 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 строк + +--- + ## Итоговые изменения | Файл | Тип | Изменения | Строк | diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index 44e6aa4..84e2918 100755 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -275,6 +275,19 @@ async def main() -> None: help="Port for WebSocket server (default: 8675)" ) + # Audio level monitoring + parser.add_argument( + "--audio-level-enabled", + action="store_true", + help="Enable audio level monitoring and send audio-level events to WebSocket" + ) + parser.add_argument( + "--audio-level-interval-ms", + type=int, + default=100, + help="Interval for audio-level events in milliseconds (default: 100)" + ) + # Sounds parser.add_argument( "--awake-wav", help="WAV file to play when wake word is detected" @@ -451,6 +464,10 @@ async def main() -> None: websocket_enabled=args.websocket_enabled, websocket_host=args.websocket_host, websocket_port=args.websocket_port, + + # Audio level + audio_level_enabled=args.audio_level_enabled, + audio_level_interval_ms=args.audio_level_interval_ms, ), timer=TimerSettings( started=split_command(args.timer_started_command), diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index 287890f..e9c28e0 100755 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -117,6 +117,10 @@ class SatelliteBase: self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set() # type: ignore self._websocket_lock = asyncio.Lock() + # Audio level monitoring + self._last_mic_level: float = 0.0 + self._audio_level_task: Optional[asyncio.Task] = None + # Debug audio recording self.wake_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None @@ -256,6 +260,12 @@ class SatelliteBase: if self.settings.event.websocket_enabled: await self.start_websocket_server() + # Запустить задачу audio level (новый) + if self.settings.event.audio_level_enabled: + self._audio_level_task = asyncio.create_task( + self._send_audio_level(), name="audio_level" + ) + self.state = State.STARTED await self.started() @@ -271,6 +281,11 @@ class SatelliteBase: if self._websocket_server: await self.stop_websocket_server() + # Остановить задачу audio level (новый) + if self._audio_level_task: + self._audio_level_task.cancel() + self._audio_level_task = None + await self._disconnect_from_services() self._disable_ping() self.state = State.STOPPED @@ -1176,6 +1191,44 @@ class SatelliteBase: except Exception as exc: _LOGGER.error(f"Error handling command {command}: {exc}") + # ------------------------------------------------------------------------- + # Audio Level Monitoring + # ------------------------------------------------------------------------- + + 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 + + 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) + # ------------------------------------------------------------------------- # Info # ------------------------------------------------------------------------- @@ -1235,15 +1288,18 @@ class AlwaysStreamingSatellite(SatelliteBase): return if AudioChunk.is_type(event.type): + # Вычислить RMS для аудио чанка + if audio_bytes is None: + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + + self._last_mic_level = self._calculate_rms(audio_bytes) + # Forward to server await self.event_to_server(event) # Debug audio recording if self.stt_audio_writer is not None: - if audio_bytes is None: - chunk = AudioChunk.from_event(event) - audio_bytes = chunk.audio - self.stt_audio_writer.write(audio_bytes) @@ -1327,13 +1383,15 @@ class VadStreamingSatellite(SatelliteBase): # Only unpack chunk once chunk: Optional[AudioChunk] = None + # Вычислить RMS для аудио чанка + if audio_bytes is None: + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + + self._last_mic_level = self._calculate_rms(audio_bytes) + # Debug audio recording if self.stt_audio_writer is not None: - if audio_bytes is None: - # Need to unpack - chunk = AudioChunk.from_event(event) - audio_bytes = chunk.audio - self.stt_audio_writer.write(audio_bytes) if ( @@ -1533,12 +1591,15 @@ class WakeStreamingSatellite(SatelliteBase): ): return + # Вычислить RMS для аудио чанка + if audio_bytes is None: + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + + self._last_mic_level = self._calculate_rms(audio_bytes) + # Debug audio recording if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None): - if audio_bytes is None: - chunk = AudioChunk.from_event(event) - audio_bytes = chunk.audio - if self.wake_audio_writer is not None: self.wake_audio_writer.write(audio_bytes) diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index 606a2be..7f74aae 100755 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -188,6 +188,13 @@ class EventSettings(ServiceSettings): websocket_port: int = 8675 """Port для WebSocket сервера.""" + # Audio level monitoring + audio_level_enabled: bool = False + """True если нужно вычислять и отправлять RMS громкость микрофона.""" + + audio_level_interval_ms: int = 100 + """Интервал отправки audio-level событий (миллисекунды).""" + @dataclass(frozen=True) class TimerSettings: