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: