feat: добавить Audio Level monitoring — RMS громкости микрофона в WebSocket
- settings.py: audio_level_enabled, audio_level_interval_ms
- satellite.py: _calculate_rms() — вычисление RMS (0.0-1.0)
- satellite.py: _send_audio_level() — фоновая задача отправки
- satellite.py: _last_mic_level, _audio_level_task атрибуты
- satellite.py: интеграция в _start() / _stop()
- satellite.py: вычисление RMS в event_from_mic() для всех классов
- __main__.py: CLI --audio-level-enabled, --audio-level-interval-ms
Событие в WebSocket (10 раз в секунду):
{type: audio-level, data: {level: 0.42}}
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
### ⏳ Ожидается
|
### ⏳ Ожидается
|
||||||
|
|
||||||
|
- [ ] **Этап WS-6: Audio level** — трансляция RMS громкости микрофона
|
||||||
- [ ] Merge в master
|
- [ ] Merge в master
|
||||||
- [ ] Production развёртывание
|
- [ ] 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
|
||||||
|
<div id="level-meter" style="width: 100%; height: 20px; background: #333;">
|
||||||
|
<div id="level-bar" style="width: 0%; height: 100%; background: #0f0;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ws = new WebSocket('ws://localhost:8675');
|
||||||
|
const levelBar = document.getElementById('level-bar');
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'audio-level') {
|
||||||
|
const percent = data.level * 100;
|
||||||
|
levelBar.style.width = percent + '%';
|
||||||
|
|
||||||
|
// Цвет от уровня
|
||||||
|
if (data.level > 0.7) {
|
||||||
|
levelBar.style.background = '#f00'; // Громко
|
||||||
|
} else if (data.level > 0.3) {
|
||||||
|
levelBar.style.background = '#ff0'; // Средне
|
||||||
|
} else {
|
||||||
|
levelBar.style.background = '#0f0'; // Тихо
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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 строк
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Итоговые изменения
|
## Итоговые изменения
|
||||||
|
|
||||||
| Файл | Тип | Изменения | Строк |
|
| Файл | Тип | Изменения | Строк |
|
||||||
|
|||||||
@@ -275,6 +275,19 @@ async def main() -> None:
|
|||||||
help="Port for WebSocket server (default: 8675)"
|
help="Port for WebSocket server (default: 8675)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Audio level monitoring
|
||||||
|
parser.add_argument(
|
||||||
|
"--audio-level-enabled",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable audio level monitoring and send audio-level events to WebSocket"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--audio-level-interval-ms",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
help="Interval for audio-level events in milliseconds (default: 100)"
|
||||||
|
)
|
||||||
|
|
||||||
# Sounds
|
# Sounds
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--awake-wav", help="WAV file to play when wake word is detected"
|
"--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_enabled=args.websocket_enabled,
|
||||||
websocket_host=args.websocket_host,
|
websocket_host=args.websocket_host,
|
||||||
websocket_port=args.websocket_port,
|
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(
|
timer=TimerSettings(
|
||||||
started=split_command(args.timer_started_command),
|
started=split_command(args.timer_started_command),
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ class SatelliteBase:
|
|||||||
self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set() # type: ignore
|
self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set() # type: ignore
|
||||||
self._websocket_lock = asyncio.Lock()
|
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
|
# Debug audio recording
|
||||||
self.wake_audio_writer: Optional[DebugAudioWriter] = None
|
self.wake_audio_writer: Optional[DebugAudioWriter] = None
|
||||||
self.stt_audio_writer: Optional[DebugAudioWriter] = None
|
self.stt_audio_writer: Optional[DebugAudioWriter] = None
|
||||||
@@ -256,6 +260,12 @@ class SatelliteBase:
|
|||||||
if self.settings.event.websocket_enabled:
|
if self.settings.event.websocket_enabled:
|
||||||
await self.start_websocket_server()
|
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
|
self.state = State.STARTED
|
||||||
await self.started()
|
await self.started()
|
||||||
|
|
||||||
@@ -271,6 +281,11 @@ class SatelliteBase:
|
|||||||
if self._websocket_server:
|
if self._websocket_server:
|
||||||
await self.stop_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()
|
await self._disconnect_from_services()
|
||||||
self._disable_ping()
|
self._disable_ping()
|
||||||
self.state = State.STOPPED
|
self.state = State.STOPPED
|
||||||
@@ -1176,6 +1191,44 @@ class SatelliteBase:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_LOGGER.error(f"Error handling command {command}: {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
|
# Info
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1235,15 +1288,18 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if AudioChunk.is_type(event.type):
|
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
|
# Forward to server
|
||||||
await self.event_to_server(event)
|
await self.event_to_server(event)
|
||||||
|
|
||||||
# Debug audio recording
|
# Debug audio recording
|
||||||
if self.stt_audio_writer is not None:
|
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)
|
self.stt_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
|
|
||||||
@@ -1327,13 +1383,15 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
# Only unpack chunk once
|
# Only unpack chunk once
|
||||||
chunk: Optional[AudioChunk] = None
|
chunk: Optional[AudioChunk] = None
|
||||||
|
|
||||||
# Debug audio recording
|
# Вычислить RMS для аудио чанка
|
||||||
if self.stt_audio_writer is not None:
|
|
||||||
if audio_bytes is None:
|
if audio_bytes is None:
|
||||||
# Need to unpack
|
|
||||||
chunk = AudioChunk.from_event(event)
|
chunk = AudioChunk.from_event(event)
|
||||||
audio_bytes = chunk.audio
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
|
self._last_mic_level = self._calculate_rms(audio_bytes)
|
||||||
|
|
||||||
|
# Debug audio recording
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
self.stt_audio_writer.write(audio_bytes)
|
self.stt_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1533,12 +1591,15 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Debug audio recording
|
# Вычислить RMS для аудио чанка
|
||||||
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
|
|
||||||
if audio_bytes is None:
|
if audio_bytes is None:
|
||||||
chunk = AudioChunk.from_event(event)
|
chunk = AudioChunk.from_event(event)
|
||||||
audio_bytes = chunk.audio
|
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 self.wake_audio_writer is not None:
|
if self.wake_audio_writer is not None:
|
||||||
self.wake_audio_writer.write(audio_bytes)
|
self.wake_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ class EventSettings(ServiceSettings):
|
|||||||
websocket_port: int = 8675
|
websocket_port: int = 8675
|
||||||
"""Port для WebSocket сервера."""
|
"""Port для WebSocket сервера."""
|
||||||
|
|
||||||
|
# Audio level monitoring
|
||||||
|
audio_level_enabled: bool = False
|
||||||
|
"""True если нужно вычислять и отправлять RMS громкость микрофона."""
|
||||||
|
|
||||||
|
audio_level_interval_ms: int = 100
|
||||||
|
"""Интервал отправки audio-level событий (миллисекунды)."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TimerSettings:
|
class TimerSettings:
|
||||||
|
|||||||
Reference in New Issue
Block a user